feat(cli): agent bridge

This commit is contained in:
rcmerci
2026-05-19 20:04:39 +08:00
parent a50407846f
commit 507a39fff4
20 changed files with 2297 additions and 7 deletions

View File

@@ -0,0 +1,298 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: agent_bridge_demo.sh [options]
Creates a fresh Logseq graph, starts `logseq agent bridge`, writes a routable
task after the bridge listener is ready, and verifies that a fake Codex worker
executed the task and that AgentBridge wrote agent-session-id.
Options:
--cli PATH Path to static/logseq-cli.js. Default: <repo-root>/static/logseq-cli.js
--root-dir DIR Logseq CLI root dir. Default: a new temp dir
--graph NAME Graph name. Default: agent-bridge-demo-<timestamp>
--repo-root DIR Repository root. Default: inferred from this script
--timeout-sec N Wait timeout for bridge events. Default: 45
-h, --help Show this help
USAGE
}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
cli_path=""
root_dir=""
graph="agent-bridge-demo-$(date +%s)"
timeout_sec=45
agent_name="AgentBridgeDemo"
task_title="AgentBridge demo task: mark this block done"
expected_session="thread-agent-bridge-demo"
bridge_pid=""
graph_created=0
while [[ $# -gt 0 ]]; do
case "$1" in
--cli)
cli_path="$2"
shift 2
;;
--root-dir)
root_dir="$2"
shift 2
;;
--graph)
graph="$2"
shift 2
;;
--repo-root)
repo_root="$2"
shift 2
;;
--timeout-sec)
timeout_sec="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 2
;;
esac
done
cli_path="${cli_path:-$repo_root/static/logseq-cli.js}"
root_dir="${root_dir:-$(mktemp -d "${TMPDIR:-/tmp}/logseq-agent-bridge-demo.XXXXXX")}"
config_path="$root_dir/cli.edn"
work_dir="$root_dir/agent-bridge-demo"
fake_bin="$work_dir/fake-bin"
bridge_log="$work_dir/agent-bridge.log"
bridge_err="$work_dir/agent-bridge.err"
codex_log="$work_dir/codex-invocations.jsonl"
cleanup() {
local status=$?
if [[ -n "${bridge_pid:-}" ]] && kill -0 "$bridge_pid" 2>/dev/null; then
kill "$bridge_pid" 2>/dev/null || true
wait "$bridge_pid" 2>/dev/null || true
fi
if [[ "$graph_created" -eq 1 ]]; then
node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output json server stop --graph "$graph" >/dev/null 2>&1 || true
fi
exit "$status"
}
trap cleanup EXIT INT TERM
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Required command is missing: $1" >&2
exit 2
fi
}
json_result() {
python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["result"])'
}
json_result_first() {
python3 -c 'import json,sys; r=json.load(sys.stdin)["data"]["result"]; print(r[0] if isinstance(r, list) else r)'
}
run_cli_json() {
node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output json "$@"
}
wait_for_file_text() {
local path="$1"
local text="$2"
local deadline=$((SECONDS + timeout_sec))
while (( SECONDS < deadline )); do
if [[ -f "$path" ]] && grep -Fq "$text" "$path"; then
return 0
fi
if [[ -n "${bridge_pid:-}" ]] && ! kill -0 "$bridge_pid" 2>/dev/null; then
echo "agent bridge exited before '$text'" >&2
[[ -f "$bridge_log" ]] && cat "$bridge_log" >&2
[[ -f "$bridge_err" ]] && cat "$bridge_err" >&2
exit 1
fi
sleep 0.2
done
echo "Timed out waiting for '$text' in $path" >&2
[[ -f "$bridge_log" ]] && cat "$bridge_log" >&2
[[ -f "$bridge_err" ]] && cat "$bridge_err" >&2
exit 1
}
query_task_status() {
run_cli_json query --graph "$graph" --query "[:find ?status-ident . :where [$1 :logseq.property/status ?status] [?status :db/ident ?status-ident]]" | json_result
}
query_agent_session() {
local payload
payload="$(run_cli_json query --graph "$graph" --query "[:find ?session . :where [$1 ?attr ?session] [?p :block/name \"agent-session-id\"] [?p :db/ident ?attr]]")"
python3 - "$root_dir" "$config_path" "$graph" "$cli_path" "$payload" <<'PY'
import json
import subprocess
import sys
payload = json.loads(sys.argv[5])
value = payload.get("data", {}).get("result")
if isinstance(value, int):
root_dir, config_path, graph, cli_path = sys.argv[1:5]
query = f"[:find ?title . :where [{value} :block/title ?title]]"
resolved = subprocess.check_output(
[
"node",
cli_path,
"--root-dir",
root_dir,
"--config",
config_path,
"--output",
"json",
"query",
"--graph",
graph,
"--query",
query,
],
text=True,
)
value = json.loads(resolved)["data"]["result"]
print("" if value is None else value)
PY
}
write_fake_codex() {
mkdir -p "$fake_bin"
cat > "$fake_bin/codex" <<'FAKE_CODEX'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -eq 1 && "$1" == "--version" ]]; then
echo "codex-cli 0.0.0-agent-bridge-demo"
exit 0
fi
if [[ "$#" -ge 3 && "$1" == "exec" && "$2" == "--json" ]]; then
prompt="$3"
python3 - "$CODEX_FAKE_LOG" "$prompt" "$@" <<'PY'
import json
import pathlib
import sys
log_path = pathlib.Path(sys.argv[1])
prompt = sys.argv[2]
args = sys.argv[3:]
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf8") as f:
f.write(json.dumps({"args": args, "prompt": prompt}, ensure_ascii=False) + "\n")
PY
block_uuid="$(python3 - "$prompt" <<'PY'
import re
import sys
match = re.search(r"^Block UUID:\s*([0-9a-fA-F-]+)\s*$", sys.argv[1], re.MULTILINE)
if not match:
raise SystemExit("Block UUID not found in AgentBridge prompt")
print(match.group(1))
PY
)"
node "$DEMO_CLI" --root-dir "$DEMO_ROOT_DIR" --config "$DEMO_CONFIG" --output json upsert task --graph "$DEMO_GRAPH" --uuid "$block_uuid" --status done >/dev/null
printf '{"type":"thread.started","thread_id":"thread-agent-bridge-demo"}\n'
exit 0
fi
echo "unexpected codex args: $*" >&2
exit 2
FAKE_CODEX
chmod +x "$fake_bin/codex"
}
require_command node
require_command python3
mkdir -p "$work_dir"
printf '{:output-format :json :agent-name "%s"}\n' "$agent_name" > "$config_path"
write_fake_codex
echo "creating graph: $graph"
echo "root dir: $root_dir"
run_cli_json graph create --graph "$graph" >/dev/null
graph_created=1
echo "starting agent bridge"
PATH="$fake_bin:$PATH" \
CODEX_FAKE_LOG="$codex_log" \
DEMO_CLI="$cli_path" \
DEMO_ROOT_DIR="$root_dir" \
DEMO_CONFIG="$config_path" \
DEMO_GRAPH="$graph" \
node "$cli_path" --root-dir "$root_dir" --config "$config_path" --output human agent bridge --graph "$graph" >"$bridge_log" 2>"$bridge_err" &
bridge_pid=$!
wait_for_file_text "$bridge_log" "listening graph changes"
task_id="$(run_cli_json upsert task --graph "$graph" --target-page AgentBridgeDemo --content "$task_title" --status todo | json_result_first)"
run_cli_json upsert block --graph "$graph" --id "$task_id" --update-properties "{\"Assignee\" \"$agent_name\"}" >/dev/null
deadline=$((SECONDS + timeout_sec))
task_status=""
agent_session=""
while (( SECONDS < deadline )); do
task_status="$(query_task_status "$task_id")"
agent_session="$(query_agent_session "$task_id")"
if [[ "$task_status" == "logseq.property/status.done" && "$agent_session" == "$expected_session" ]]; then
break
fi
if [[ -n "${bridge_pid:-}" ]] && ! kill -0 "$bridge_pid" 2>/dev/null; then
echo "agent bridge exited before verification completed" >&2
cat "$bridge_log" >&2
cat "$bridge_err" >&2
exit 1
fi
sleep 0.5
done
if [[ "$task_status" != "logseq.property/status.done" ]]; then
echo "Expected task status done, got: ${task_status:-<empty>}" >&2
cat "$bridge_log" >&2
cat "$bridge_err" >&2
exit 1
fi
if [[ "$agent_session" != "$expected_session" ]]; then
echo "Expected agent-session-id $expected_session, got: ${agent_session:-<empty>}" >&2
cat "$bridge_log" >&2
cat "$bridge_err" >&2
exit 1
fi
python3 - "$codex_log" "$task_title" "$graph" <<'PY'
import json
import pathlib
import sys
log_path = pathlib.Path(sys.argv[1])
task_title = sys.argv[2]
graph = sys.argv[3]
lines = [json.loads(line) for line in log_path.read_text(encoding="utf8").splitlines() if line.strip()]
if len(lines) != 1:
raise SystemExit(f"expected one Codex invocation, got {len(lines)}")
prompt = lines[0]["prompt"]
if task_title not in prompt:
raise SystemExit("task title missing from Codex prompt")
if f"Graph: {graph}" not in prompt:
raise SystemExit("graph missing from Codex prompt")
if "Block UUID:" not in prompt:
raise SystemExit("block uuid missing from Codex prompt")
PY
echo "task status: done"
echo "agent-session-id: $agent_session"
echo "agent bridge demo completed"

View File

@@ -0,0 +1,270 @@
#!/usr/bin/env python3
import argparse
import json
import os
import pathlib
import subprocess
import time
TASK_TITLE = "测试 agent bridge 功能把当前task status设置为done"
EXPECTED_SESSION = "thread-e2e-agent-bridge"
def write_fake_codex(fake_bin):
fake_codex = fake_bin / "codex"
fake_codex.parent.mkdir(parents=True, exist_ok=True)
fake_codex.write_text(
"""#!/usr/bin/env python3
import json
import os
import pathlib
import sys
args = sys.argv[1:]
if args == ["--version"]:
print("codex-cli 0.0.0-e2e")
sys.exit(0)
if args[:2] == ["exec", "--json"]:
prompt = args[2] if len(args) > 2 else ""
log_path = pathlib.Path(os.environ["CODEX_FAKE_LOG"])
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("a", encoding="utf8") as f:
f.write(json.dumps({"args": args, "prompt": prompt}, ensure_ascii=False) + "\\n")
print(json.dumps({"type": "thread.started", "thread_id": "thread-e2e-agent-bridge"}), flush=True)
sys.exit(0)
print("unexpected codex args: " + repr(args), file=sys.stderr)
sys.exit(2)
""",
encoding="utf8",
)
fake_codex.chmod(0o755)
def run_json(cli, repo_root, root_dir, config, graph, query):
result = subprocess.run(
cli
+ [
"--root-dir",
root_dir,
"--config",
config,
"--output",
"json",
"query",
"--graph",
graph,
"--query",
query,
],
cwd=repo_root,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
return None
return json.loads(result.stdout).get("data", {}).get("result")
def run_cli(cli, repo_root, root_dir, config, graph, extra_args):
result = subprocess.run(
cli
+ [
"--root-dir",
root_dir,
"--config",
config,
"--output",
"json",
]
+ extra_args,
cwd=repo_root,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
raise SystemExit(
"CLI command failed: {}\nstdout:\n{}\nstderr:\n{}".format(
" ".join(extra_args), result.stdout, result.stderr
)
)
return json.loads(result.stdout) if result.stdout.strip() else None
def deref_session_value(cli, repo_root, root_dir, config, graph, value):
if not isinstance(value, int):
return value
return run_json(
cli,
repo_root,
root_dir,
config,
graph,
f"[:find ?title . :where [{value} :block/title ?title]]",
)
def read_text(path):
return path.read_text(encoding="utf8", errors="replace")
def wait_for_log(path, text, process):
deadline = time.time() + 30
while time.time() < deadline:
if path.exists() and text in read_text(path):
return
if process.poll() is not None:
raise SystemExit(
"agent bridge exited before {}\nstdout:\n{}".format(
text, read_text(path)
)
)
time.sleep(0.2)
raise SystemExit("agent bridge did not log {!r}\nstdout:\n{}".format(text, read_text(path)))
def assign_task(cli, repo_root, root_dir, config, graph):
task_id = run_json(
cli,
repo_root,
root_dir,
config,
graph,
'[:find ?e . :where [?e :block/title "{}"]]'.format(TASK_TITLE),
)
if task_id is None:
raise SystemExit("task block was not found")
run_cli(
cli,
repo_root,
root_dir,
config,
graph,
[
"upsert",
"block",
"--graph",
graph,
"--id",
str(task_id),
"--update-properties",
'{{"Assignee" "{}"}}'.format(os.uname().nodename),
],
)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--cli", required=True)
parser.add_argument("--root-dir", required=True)
parser.add_argument("--config", required=True)
parser.add_argument("--graph", required=True)
parser.add_argument("--tmp-dir", required=True)
parser.add_argument("--repo-root", required=True)
parser.add_argument("--prepare-fake-codex-only", action="store_true")
parser.add_argument("--assign-after-start", action="store_true")
args = parser.parse_args()
repo_root = pathlib.Path(args.repo_root)
tmp_dir = pathlib.Path(args.tmp_dir)
fake_bin = tmp_dir / "fake-bin"
bridge_log = tmp_dir / "agent-bridge.log"
bridge_err = tmp_dir / "agent-bridge.err"
codex_log = tmp_dir / "codex-invocations.jsonl"
cli = ["node", args.cli]
write_fake_codex(fake_bin)
if args.prepare_fake_codex_only:
return
env = os.environ.copy()
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
env["CODEX_FAKE_LOG"] = str(codex_log)
with bridge_log.open("wb") as out, bridge_err.open("wb") as err:
bridge = subprocess.Popen(
cli
+ [
"--root-dir",
args.root_dir,
"--config",
args.config,
"--output",
"human",
"agent",
"bridge",
"--graph",
args.graph,
],
cwd=repo_root,
env=env,
stdout=out,
stderr=err,
)
try:
if args.assign_after_start:
wait_for_log(bridge_log, "listening graph changes", bridge)
assign_task(cli, repo_root, args.root_dir, args.config, args.graph)
deadline = time.time() + 30
session = None
query = (
'[:find ?session . :where [?e :block/title "{}"] '
'[?p :block/name "agent-session-id"] '
"[?p :db/ident ?attr] [?e ?attr ?session]]"
).format(TASK_TITLE)
while time.time() < deadline:
session = deref_session_value(
cli,
repo_root,
args.root_dir,
args.config,
args.graph,
run_json(cli, repo_root, args.root_dir, args.config, args.graph, query),
)
if session == EXPECTED_SESSION:
break
if bridge.poll() is not None:
raise SystemExit(
"agent bridge exited early with {}\nstdout:\n{}\nstderr:\n{}".format(
bridge.returncode, read_text(bridge_log), read_text(bridge_err)
)
)
time.sleep(0.5)
else:
raise SystemExit(
"agent-session-id was not written; last session={!r}\nstdout:\n{}\nstderr:\n{}".format(
session, read_text(bridge_log), read_text(bridge_err)
)
)
lines = [
json.loads(line)
for line in codex_log.read_text(encoding="utf8").splitlines()
if line.strip()
]
assert len(lines) == 1, lines
prompt = lines[0]["prompt"]
assert TASK_TITLE in prompt, prompt
assert "Graph: " + args.graph in prompt, prompt
assert "Block UUID:" in prompt, prompt
print("agent bridge routed task to " + session)
finally:
if bridge.poll() is None:
bridge.terminate()
try:
bridge.wait(timeout=5)
except subprocess.TimeoutExpired:
bridge.kill()
bridge.wait(timeout=5)
if __name__ == "__main__":
main()