enhance(cli): update agent bridge, prompt store in graph

This commit is contained in:
rcmerci
2026-05-20 23:35:05 +08:00
parent b940287808
commit 1712bff4e8
4 changed files with 908 additions and 35 deletions

View File

@@ -131,6 +131,27 @@ def run_cli(cli, repo_root, root_dir, config, graph, extra_args):
return json.loads(result.stdout) if result.stdout.strip() else None
def run_cli_with_env(cli, repo_root, root_dir, config, extra_args, env):
result = subprocess.run(
cli
+ [
"--root-dir",
root_dir,
"--config",
config,
"--output",
"json",
]
+ extra_args,
cwd=repo_root,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return result
def deref_session_value(cli, repo_root, root_dir, config, graph, value):
if not isinstance(value, int):
return value
@@ -471,6 +492,200 @@ def run_parallel_assignment_check(cli, repo_root, root_dir, config, graph, tmp_d
bridge.wait(timeout=5)
def edn_string(value):
return json.dumps(value, ensure_ascii=False)
def write_task_template_file(path, marker):
template = "\n".join(
[
marker,
"Graph: {{graph}}",
"Block UUID: {{block-uuid}}",
"AgentBridge name: {{agent-name}}",
"{{task-block-tree}}",
]
)
path.write_text(
"""[{:block/title "Task prompt template"
:block/children [{:block/title "Description: Custom task template for this graph."}
{:block/title "Template variables: {{graph}}, {{block-uuid}}, {{agent-name}}, and {{task-block-tree}}."}
{:block/title %s}]}]"""
% edn_string("```text\n" + template + "\n```"),
encoding="utf8",
)
def write_invalid_task_template_file(path):
path.write_text(
"""[{:block/title "Task prompt template"
:block/children [{:block/title "Description: Invalid task template for lint coverage."}
{:block/title %s}]}]"""
% edn_string("```text\nBroken {{graph}} {{unknown-var}}\n```"),
encoding="utf8",
)
def install_task_template(cli, repo_root, root_dir, config, graph, tmp_dir, marker):
run_cli(
cli,
repo_root,
root_dir,
config,
graph,
["upsert", "page", "--graph", graph, "--page", "AgentBridge"],
)
blocks_file = tmp_dir / ("task-template-" + graph + ".edn")
write_task_template_file(blocks_file, marker)
run_cli(
cli,
repo_root,
root_dir,
config,
graph,
[
"upsert",
"block",
"--graph",
graph,
"--target-page",
"AgentBridge",
"--blocks-file",
str(blocks_file),
],
)
def install_invalid_task_template(cli, repo_root, root_dir, config, graph, tmp_dir):
run_cli(
cli,
repo_root,
root_dir,
config,
graph,
["upsert", "page", "--graph", graph, "--page", "AgentBridge"],
)
blocks_file = tmp_dir / ("invalid-task-template-" + graph + ".edn")
write_invalid_task_template_file(blocks_file)
run_cli(
cli,
repo_root,
root_dir,
config,
graph,
[
"upsert",
"block",
"--graph",
graph,
"--target-page",
"AgentBridge",
"--blocks-file",
str(blocks_file),
],
)
def dry_run_prompt(cli, repo_root, root_dir, config, graph, env):
result = run_cli_with_env(
cli,
repo_root,
root_dir,
config,
["agent", "bridge", "--graph", graph, "--dry-run"],
env,
)
if result.returncode != 0:
raise SystemExit(
"agent bridge dry-run failed for {}\nstdout:\n{}\nstderr:\n{}".format(
graph, result.stdout, result.stderr
)
)
payload = json.loads(result.stdout)
commands = payload.get("data", {}).get("commands", [])
if len(commands) != 1:
raise SystemExit("expected one dry-run command for {}, got {!r}".format(graph, commands))
command = commands[0].get("command", [])
if len(command) < 4:
raise SystemExit("dry-run command did not contain a prompt: {!r}".format(command))
return command[3]
def assert_contains(text, expected):
if expected not in text:
raise SystemExit("expected {!r} in:\n{}".format(expected, text))
def assert_not_contains(text, unexpected):
if unexpected in text:
raise SystemExit("did not expect {!r} in:\n{}".format(unexpected, text))
def run_prompt_template_graph_check(cli, repo_root, root_dir, config, graph, tmp_dir):
graph_a = graph
graph_b = graph + "-other"
graph_bad = graph + "-invalid"
marker_a = "CUSTOM GRAPH A TEMPLATE"
marker_b = "CUSTOM GRAPH B TEMPLATE"
fake_bin = tmp_dir / "fake-bin"
env = os.environ.copy()
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
env["CODEX_FAKE_LOG"] = str(tmp_dir / "codex-dry-run.jsonl")
for graph_name in [graph_b, graph_bad]:
run_cli(
cli,
repo_root,
root_dir,
config,
graph_name,
["graph", "create", "--graph", graph_name],
)
for graph_name in [graph_a, graph_b, graph_bad]:
create_task(cli, repo_root, root_dir, config, graph_name, TASK_TITLE)
assign_task(cli, repo_root, root_dir, config, graph_name)
install_task_template(cli, repo_root, root_dir, config, graph_a, tmp_dir, marker_a)
prompt_a = dry_run_prompt(cli, repo_root, root_dir, config, graph_a, env)
assert_contains(prompt_a, marker_a)
assert_contains(prompt_a, "Graph: " + graph_a)
assert_not_contains(prompt_a, marker_b)
assert_not_contains(prompt_a, "You are handling a Logseq AgentBridge task.")
prompt_b_default = dry_run_prompt(cli, repo_root, root_dir, config, graph_b, env)
assert_contains(prompt_b_default, "You are handling a Logseq AgentBridge task.")
assert_contains(prompt_b_default, "Graph: " + graph_b)
assert_not_contains(prompt_b_default, marker_a)
install_task_template(cli, repo_root, root_dir, config, graph_b, tmp_dir, marker_b)
prompt_b = dry_run_prompt(cli, repo_root, root_dir, config, graph_b, env)
assert_contains(prompt_b, marker_b)
assert_contains(prompt_b, "Graph: " + graph_b)
assert_not_contains(prompt_b, marker_a)
assert_not_contains(prompt_b, "You are handling a Logseq AgentBridge task.")
prompt_a_again = dry_run_prompt(cli, repo_root, root_dir, config, graph_a, env)
assert_contains(prompt_a_again, marker_a)
assert_not_contains(prompt_a_again, marker_b)
install_invalid_task_template(cli, repo_root, root_dir, config, graph_bad, tmp_dir)
bad_result = run_cli_with_env(
cli,
repo_root,
root_dir,
config,
["agent", "bridge", "--graph", graph_bad, "--dry-run"],
env,
)
if bad_result.returncode == 0:
raise SystemExit("invalid graph prompt template unexpectedly passed:\n" + bad_result.stdout)
assert_contains(bad_result.stdout + bad_result.stderr, "agent-prompt-template-invalid")
print("agent bridge graph prompt templates are isolated and linted")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--cli", required=True)
@@ -483,6 +698,7 @@ def main():
parser.add_argument("--assign-after-start", action="store_true")
parser.add_argument("--parallel-assignment-check", action="store_true")
parser.add_argument("--comment-mention-check", action="store_true")
parser.add_argument("--prompt-template-graph-check", action="store_true")
args = parser.parse_args()
repo_root = pathlib.Path(args.repo_root)
@@ -505,6 +721,10 @@ def main():
run_comment_mention_check(cli, repo_root, args.root_dir, args.config, args.graph, tmp_dir)
return
if args.prompt_template_graph_check:
run_prompt_template_graph_check(cli, repo_root, args.root_dir, args.config, args.graph, tmp_dir)
return
env = os.environ.copy()
env["PATH"] = str(fake_bin) + os.pathsep + env.get("PATH", "")
env["CODEX_FAKE_LOG"] = str(codex_log)

View File

@@ -1331,6 +1331,24 @@ PY"
"{{cli}} --root-dir '{{tmp-dir}}/parallel-root' --config '{{tmp-dir}}/parallel-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-parallel"
"{{cli}} --root-dir '{{tmp-dir}}/comment-root' --config '{{tmp-dir}}/comment-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-comment"],
:tags [:agent]}
{:id "agent-bridge-prompt-template-graphs",
:setup
["mkdir -p '{{tmp-dir}}/prompt-template-root' && printf '{:output-format :json}\\n' > '{{tmp-dir}}/prompt-template-root/cli.edn' && {{cli}} --root-dir '{{tmp-dir}}/prompt-template-root' --config '{{tmp-dir}}/prompt-template-root/cli.edn' --output json graph create --graph cli-e2e-agent-bridge-template-a >/dev/null"],
:cmds
["python3 '{{repo-root}}/cli-e2e/scripts/agent_bridge_e2e.py' --cli '{{repo-root}}/static/logseq-cli.js' --root-dir '{{tmp-dir}}/prompt-template-root' --config '{{tmp-dir}}/prompt-template-root/cli.edn' --graph cli-e2e-agent-bridge-template-a --tmp-dir '{{tmp-dir}}/prompt-template-work' --repo-root '{{repo-root}}' --prompt-template-graph-check"],
:expect
{:exit 0,
:stdout-contains ["agent bridge graph prompt templates are isolated and linted"]},
:covers
{:commands ["agent bridge"],
:options
{:global ["--config" "--graph" "--root-dir" "--output"],
:agent ["--dry-run"]}},
:cleanup
["{{cli}} --root-dir '{{tmp-dir}}/prompt-template-root' --config '{{tmp-dir}}/prompt-template-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-template-a"
"{{cli}} --root-dir '{{tmp-dir}}/prompt-template-root' --config '{{tmp-dir}}/prompt-template-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-template-a-other"
"{{cli}} --root-dir '{{tmp-dir}}/prompt-template-root' --config '{{tmp-dir}}/prompt-template-root/cli.edn' --output json server stop --graph cli-e2e-agent-bridge-template-a-invalid"],
:tags [:agent]}
{:id "skill-show-human",
:cmds ["{{cli}} skill show"],
:expect

View File

@@ -5,6 +5,7 @@
["os" :as os]
["path" :as node-path]
[cljs.reader :as reader]
[clojure.set :as set]
[clojure.string :as string]
[lambdaisland.glogi :as log]
[logseq.cli.command.core :as core]
@@ -12,6 +13,7 @@
[logseq.cli.server :as cli-server]
[logseq.cli.transport :as transport]
[logseq.common.util :as common-util]
[logseq.db :as ldb]
[promesa.core :as p]))
(def ^:private bridge-spec
@@ -175,52 +177,157 @@
[block]
(some-> (:block/uuid block) str))
(defn build-codex-prompt
[{:keys [graph agent-name block tree-text]}]
(def task-prompt-template-title "Task prompt template")
(def comment-prompt-template-title "Comment prompt template")
(def ^:private default-task-prompt-template
(string/join
"\n"
["You are handling a Logseq AgentBridge task."
""
(str "Graph: " graph)
(str "Block UUID: " (block-uuid-str block))
(str "AgentBridge name: " agent-name)
"Graph: {{graph}}"
"Block UUID: {{block-uuid}}"
"AgentBridge name: {{agent-name}}"
""
"Do not operate outside the target graph."
"Write task results back into the graph."
"Report the final status, files changed, commands run, verification, and any blockers."
""
"Task block tree:"
(or tree-text (:block/title block) "")]))
"{{task-block-tree}}"]))
(defn- build-comment-codex-prompt
[{:keys [graph agent-name comment-tree-text comments-area-tree-text target-tree-texts]
comment-block :comment}]
(def ^:private default-comment-prompt-template
(string/join
"\n"
["You are handling a Logseq AgentBridge comment request."
""
(str "Graph: " graph)
(str "Comment UUID: " (block-uuid-str comment-block))
(str "AgentBridge name: " agent-name)
"Graph: {{graph}}"
"Comment UUID: {{comment-uuid}}"
"AgentBridge name: {{agent-name}}"
""
"Do not operate outside the target graph."
"Complete the request from the mentioned comment."
"Report the final status, files changed, commands run, verification, and any blockers."
""
"Comment target context:"
(string/join "\n" (remove string/blank? target-tree-texts))
"{{comment-target-context}}"
""
"Comment thread context:"
(or comments-area-tree-text (:block/title (:block/parent comment-block)) "")
"{{comment-thread-context}}"
""
"Requesting comment:"
(or comment-tree-text (:block/title comment-block) "")
"{{requesting-comment}}"
""
"Reply instructions:"
"For a short reply, append a comment after the requesting comment."
"For a long reply, write a normal block tree after the comments area and append a comment that references that tree."
"If the request is blocked or fails, make that clear in the reply."]))
(def ^:private prompt-template-vars
{:task #{"graph"
"block-uuid"
"agent-name"
"task-block-tree"}
:comment #{"graph"
"comment-uuid"
"agent-name"
"comment-target-context"
"comment-thread-context"
"requesting-comment"}})
(def ^:private required-prompt-template-vars prompt-template-vars)
(defn- renderable-prompt-template-var-names
[template]
(->> (re-seq #"('?)(\{\{([A-Za-z0-9-]+)\}\})('?)" (or template ""))
(keep (fn [[_ open-quote _ var-name close-quote]]
(when-not (and (= "'" open-quote)
(= "'" close-quote))
var-name)))
set))
(defn validate-prompt-template
[template-kind template]
(let [vars (renderable-prompt-template-var-names template)
allowed-vars (get prompt-template-vars template-kind)
required-vars (get required-prompt-template-vars template-kind)]
(if (nil? allowed-vars)
{:ok? false
:error {:code :unknown-template-kind
:template template-kind}}
(let [unknown-vars (set/difference vars allowed-vars)
missing-vars (set/difference required-vars vars)]
(cond
(string/blank? (or template ""))
{:ok? false
:error {:code :missing-template-code-block
:template template-kind}}
(seq unknown-vars)
{:ok? false
:error {:code :unknown-template-vars
:template template-kind
:vars unknown-vars}}
(seq missing-vars)
{:ok? false
:error {:code :missing-template-vars
:template template-kind
:vars missing-vars}}
:else
{:ok? true})))))
(defn- validate-prompt-templates!
[templates]
(doseq [[template-kind template] templates]
(let [result (validate-prompt-template template-kind template)]
(when-not (:ok? result)
(throw (ex-info "agent bridge prompt template is invalid"
(assoc (:error result)
:code :agent-prompt-template-invalid
:reason (get-in result [:error :code])))))))
templates)
(defn- render-prompt-template
[template-kind template vars]
(validate-prompt-templates! {template-kind template})
(string/replace
template
#"\{\{([A-Za-z0-9-]+)\}\}"
(fn [[_ var-name]]
(if (contains? vars var-name)
(str (get vars var-name))
(throw (ex-info "agent bridge prompt template var has no value"
{:code :agent-prompt-template-var-missing
:template template-kind
:var var-name}))))))
(defn build-codex-prompt
[{:keys [graph agent-name block tree-text prompt-template]}]
(render-prompt-template
:task
(or prompt-template default-task-prompt-template)
{"graph" graph
"block-uuid" (block-uuid-str block)
"agent-name" agent-name
"task-block-tree" (or tree-text (:block/title block) "")}))
(defn- build-comment-codex-prompt
[{:keys [graph agent-name comment-tree-text comments-area-tree-text target-tree-texts]
comment-block :comment
prompt-template :prompt-template}]
(render-prompt-template
:comment
(or prompt-template default-comment-prompt-template)
{"graph" graph
"comment-uuid" (block-uuid-str comment-block)
"agent-name" agent-name
"comment-target-context" (string/join "\n" (remove string/blank? target-tree-texts))
"comment-thread-context" (or comments-area-tree-text (:block/title (:block/parent comment-block)) "")
"requesting-comment" (or comment-tree-text (:block/title comment-block) "")}))
(defn build-codex-command
[prompt {:keys [codex-bin]}]
[(or (trim-non-empty codex-bin) "codex") "exec" "--json" prompt])
@@ -423,7 +530,15 @@
(def agent-bridge-registry-page "AgentBridge")
(def agent-bridge-registry-page-query
'[:find [(pull ?p [:db/id :block/uuid :block/name :block/title]) ...]
'[:find [(pull ?p [:db/id
:block/uuid
:block/name
:block/title
:logseq.property/deleted-at
{:block/parent [:db/id
:logseq.property/deleted-at
{:block/parent [:db/id
:logseq.property/deleted-at]}]}]) ...]
:in $ ?page-name
:where
[?p :block/name ?page-name]])
@@ -435,6 +550,238 @@
[?b :block/parent ?page-id]
[?b :block/title ?agent-name]])
(declare ensure-registry-page!)
(def agent-bridge-prompt-template-blocks-query
'[:find [(pull ?b [:db/id
:block/uuid
:block/title
:block/order
{:block/_parent [:db/id
:block/uuid
:block/title
:block/order
{:block/_parent [:db/id
:block/uuid
:block/title
:block/order]}]}]) ...]
:in $ ?page-id
:where
[?b :block/parent ?page-id]])
(defn- prompt-template-default
[template-kind]
(case template-kind
:task default-task-prompt-template
:comment default-comment-prompt-template))
(defn- prompt-template-title
[template-kind]
(case template-kind
:task task-prompt-template-title
:comment comment-prompt-template-title))
(defn- prompt-template-var-description
[template-kind]
(case template-kind
:task
(string/join
"\n"
["Template variables:"
"```text"
"'{{graph}}': The graph name passed to `logseq agent bridge`."
"'{{block-uuid}}': The UUID of the routed task block."
"'{{agent-name}}': The current AgentBridge name."
"'{{task-block-tree}}': The routed task block tree rendered as outline text."
"```"])
:comment
(string/join
"\n"
["Template variables:"
"```text"
"'{{graph}}': The graph name passed to `logseq agent bridge`."
"'{{comment-uuid}}': The UUID of the requesting comment block."
"'{{agent-name}}': The current AgentBridge name."
"'{{comment-target-context}}': The block trees targeted by the comments area."
"'{{comment-thread-context}}': The complete comments area block tree."
"'{{requesting-comment}}': The requesting comment block tree."
"```"])))
(defn- prompt-template-description
[template-kind]
(case template-kind
:task
"Description: Used when AgentBridge routes an assigned TODO Task block to Codex."
:comment
"Description: Used when AgentBridge routes an AgentBridge mention in a Comment block to Codex."))
(defn- default-prompt-template-block
[template-kind]
{:block/title (prompt-template-title template-kind)
:block/children [{:block/title (prompt-template-description template-kind)}
{:block/title (prompt-template-var-description template-kind)}
{:block/title (str "```text\n"
(prompt-template-default template-kind)
"\n```")}]})
(defn- ensure-prompt-template-block-uuids
[blocks]
(mapv (fn ensure-block-uuid [block]
(let [block (cond-> block
(nil? (:block/uuid block))
(assoc :block/uuid (random-uuid)))]
(if (seq (:block/children block))
(update block :block/children ensure-prompt-template-block-uuids)
block)))
blocks))
(defn- flatten-prompt-template-blocks
[blocks]
(letfn [(walk [parent-uuid block]
(let [children (:block/children block)
block-uuid (:block/uuid block)
block (cond-> (dissoc block :block/children)
parent-uuid
(assoc :block/parent [:block/uuid parent-uuid]))]
(into [block]
(mapcat #(walk block-uuid %) children))))]
(->> blocks
ensure-prompt-template-block-uuids
(mapcat #(walk nil %))
vec)))
(defn- child-blocks
[block]
(->> (or (:block/children block)
(:block/_parent block)
[])
(sort-by #(or (:block/order %) 0))))
(defn- block-title-tree
[block]
(cons (:block/title block)
(mapcat block-title-tree (child-blocks block))))
(defn- code-blocks-in-text
[text]
(when (string? text)
(map second (re-seq #"(?s)```[^\n`]*\n(.*?)```" text))))
(defn- prompt-template-from-block
[template-kind block]
(let [templates (vec (mapcat code-blocks-in-text (block-title-tree block)))
renderable-templates (filterv #(-> (validate-prompt-template template-kind %) :ok?)
templates)]
(cond
(empty? templates)
(throw (ex-info "agent bridge prompt template code block is missing"
{:code :agent-prompt-template-invalid
:reason :missing-template-code-block
:template template-kind}))
(= 1 (count renderable-templates))
(first renderable-templates)
(> (count renderable-templates) 1)
(throw (ex-info "agent bridge prompt template must contain one code block"
{:code :agent-prompt-template-invalid
:reason :multiple-template-code-blocks
:template template-kind}))
(= 1 (count templates))
(do
(validate-prompt-templates! {template-kind (first templates)})
(first templates))
:else
(throw (ex-info "agent bridge prompt template code block is missing"
{:code :agent-prompt-template-invalid
:reason :missing-template-code-block
:template template-kind})))))
(defn- missing-template-code-block-error?
[error]
(let [data (ex-data error)]
(and (= :agent-prompt-template-invalid (:code data))
(= :missing-template-code-block (:reason data)))))
(defn- prompt-template-blocks-by-title
[blocks]
(reduce (fn [acc block]
(let [title (:block/title block)]
(if (contains? #{task-prompt-template-title
comment-prompt-template-title}
title)
(assoc acc title block)
acc)))
{}
blocks))
(defn ensure-agent-bridge-prompt-templates!
[cfg repo]
(p/let [page (ensure-registry-page! cfg repo)
page-id (:db/id page)
page-uuid (:block/uuid page)
_ (when-not page-id
(throw (ex-info "agent bridge registry page not found"
{:code :agent-prompt-template-initialization-failed})))
_ (when-not page-uuid
(throw (ex-info "agent bridge registry page uuid not found"
{:code :agent-prompt-template-initialization-failed})))
blocks (transport/invoke cfg :thread-api/q
[repo [agent-bridge-prompt-template-blocks-query page-id]])]
(let [blocks-by-title (prompt-template-blocks-by-title blocks)
template-state (reduce (fn [state template-kind]
(if-let [block (get blocks-by-title (prompt-template-title template-kind))]
(try
(assoc-in state [:templates template-kind]
(prompt-template-from-block template-kind block))
(catch :default e
(if (missing-template-code-block-error? e)
(assoc-in state [:repair-blocks template-kind] block)
(throw e))))
(update state :missing-kinds conj template-kind)))
{:templates {}
:missing-kinds []
:repair-blocks {}}
[:task :comment])
existing-templates (:templates template-state)
_ (validate-prompt-templates! existing-templates)
missing-kinds (:missing-kinds template-state)
repair-blocks (:repair-blocks template-state)]
(p/let [_ (when (seq missing-kinds)
(transport/invoke cfg :thread-api/apply-outliner-ops
[repo [[:insert-blocks [(flatten-prompt-template-blocks
(mapv default-prompt-template-block missing-kinds))
page-uuid
{:outliner-op :insert-blocks
:sibling? false
:bottom? true
:keep-uuid? true}]]]
{}]))
_ (when (seq repair-blocks)
(p/all
(mapv (fn [[template-kind block]]
(transport/invoke cfg :thread-api/apply-outliner-ops
[repo [[:insert-blocks [(flatten-prompt-template-blocks
(:block/children (default-prompt-template-block template-kind)))
(:block/uuid block)
{:outliner-op :insert-blocks
:sibling? false
:bottom? true
:keep-uuid? true}]]]
{}]))
repair-blocks)))]
(validate-prompt-templates!
(reduce (fn [templates template-kind]
(assoc templates
template-kind
(or (get existing-templates template-kind)
(prompt-template-default template-kind))))
{}
[:task :comment]))))))
(defn random-bridge-block-uuid
[]
(random-uuid))
@@ -443,6 +790,10 @@
[entities]
(first (filter :db/id entities)))
(defn- first-live-entity
[entities]
(first (remove ldb/recycled? (filter :db/id entities))))
(defn- registry-page-name
[]
(common-util/page-name-sanity-lc agent-bridge-registry-page))
@@ -452,7 +803,7 @@
(p/let [pages (transport/invoke cfg :thread-api/q
[repo [agent-bridge-registry-page-query
(registry-page-name)]])]
(first-entity pages)))
(first-live-entity pages)))
(defn- ensure-registry-page!
[cfg repo]
@@ -576,12 +927,13 @@
(map #(assoc % "Assignee" agent-name) blocks))))))
(defn- dry-run-commands
[graph agent-name tasks]
[graph agent-name prompt-templates tasks]
(mapv (fn [{:keys [block tree-text]}]
(let [prompt (build-codex-prompt {:graph graph
:agent-name agent-name
:block block
:tree-text tree-text})
:tree-text tree-text
:prompt-template (:task prompt-templates)})
command (build-codex-command prompt {})]
{:block (block-uuid-str block)
:backend :codex
@@ -607,11 +959,12 @@
:updated-at (js/Date.now)})
(defn- route-task!
[cfg {:keys [repo graph agent-name]} {:keys [block tree-text]}]
[cfg {:keys [repo graph agent-name prompt-templates]} {:keys [block tree-text]}]
(let [prompt (build-codex-prompt {:graph graph
:agent-name agent-name
:block block
:tree-text tree-text})
:tree-text tree-text
:prompt-template (:task prompt-templates)})
command (build-codex-command prompt {})
preview (command-preview command)]
(emit-log! cfg (log-line (str "Codex command prepared for " (block-uuid-str block) ": " preview)))
@@ -660,11 +1013,12 @@
(route-task! cfg opts task))))
(defn- process-tasks!
[cfg {:keys [repo graph agent-name]}]
[cfg {:keys [repo graph agent-name prompt-templates]}]
(p/let [tasks (list-routable-tasks cfg repo agent-name)]
(p/all (mapv #(route-task-once! cfg {:repo repo
:graph graph
:agent-name agent-name}
:agent-name agent-name
:prompt-templates prompt-templates}
%)
tasks))))
@@ -897,7 +1251,7 @@
trim-non-empty))))
(defn- route-comment!
[cfg {:keys [repo graph agent-name]} comment-block]
[cfg {:keys [repo graph agent-name prompt-templates]} comment-block]
(p/catch
(p/let [comment-uuid (:block/uuid comment-block)
_ (when-not comment-uuid
@@ -920,7 +1274,8 @@
:comment comment-block
:target-tree-texts target-tree-texts
:comments-area-tree-text comments-area-tree-text
:comment-tree-text comment-tree-text})
:comment-tree-text comment-tree-text
:prompt-template (:comment prompt-templates)})
resume-session-id (some #(comment-target-session-id agent-name session-property-ident %) target-blocks)
command (if resume-session-id
(build-codex-resume-command resume-session-id prompt {})
@@ -997,7 +1352,7 @@
(p/all routing)))))
(defn- listen-forever!
[cfg {:keys [repo graph agent-name]}]
[cfg {:keys [repo graph agent-name prompt-templates]}]
(let [routing-blocks* (atom #{})
handle-error! (fn [e]
(emit-log! cfg (log-line (str "Codex invocation failed: "
@@ -1015,6 +1370,7 @@
{:repo repo
:graph graph
:agent-name agent-name
:prompt-templates prompt-templates
:routing-blocks* routing-blocks*}
payload)
(p/catch handle-error!))
@@ -1044,11 +1400,13 @@
(if-not (codex-available? nil)
(bridge-error :codex-not-found "codex executable is not available")
(p/let [cfg (cli-server/ensure-server! config repo)
logs (conj logs (log-line "checking prompt templates ..."))
prompt-templates (ensure-agent-bridge-prompt-templates! cfg repo)
logs (conj logs (log-line "registering agent bridge ..."))
_ (register-agent-bridge! cfg repo agent-name)]
(if (:dry-run? action)
(p/let [tasks (list-routable-tasks cfg repo agent-name)
commands (dry-run-commands graph agent-name tasks)
commands (dry-run-commands graph agent-name prompt-templates tasks)
logs (into (conj logs (log-line "listening graph changes ..."))
(map (fn [{:keys [block preview]}]
(log-line (str "would run Codex command for " block ": " preview)))
@@ -1066,7 +1424,8 @@
(if (:process-once? action)
(p/let [routed (process-tasks! cfg {:repo repo
:graph graph
:agent-name agent-name})]
:agent-name agent-name
:prompt-templates prompt-templates})]
{:status :ok
:command :agent-bridge
:data {:mode :processed-once
@@ -1075,7 +1434,9 @@
:routed routed}})
(p/let [_ (process-tasks! cfg {:repo repo
:graph graph
:agent-name agent-name})]
:agent-name agent-name
:prompt-templates prompt-templates})]
(listen-forever! cfg {:repo repo
:graph graph
:agent-name agent-name}))))))))))))
:agent-name agent-name
:prompt-templates prompt-templates}))))))))))))

View File

@@ -211,6 +211,268 @@
(is (string/starts-with? preview "codex exec --json '"))
(is (string/includes? preview "Ship the CLI bridge")))))
(deftest test-prompt-templates
(testing "prompt builders render supplied graph templates"
(let [prompt (agent-command/build-codex-prompt
{:graph "demo"
:agent-name "build-host"
:block (task-block {})
:tree-text "- Ship the CLI bridge"
:prompt-template "Task {{graph}} {{block-uuid}} {{agent-name}}\n{{task-block-tree}}"})
comment-prompt (#'agent-command/build-comment-codex-prompt
{:graph "demo"
:agent-name "build-host"
:comment (comment-block {})
:target-tree-texts ["- Target block"]
:comments-area-tree-text "- Comments"
:comment-tree-text "- [[build-host]] summarize"
:prompt-template "Comment {{graph}} {{comment-uuid}} {{agent-name}}\n{{comment-target-context}}\n{{comment-thread-context}}\n{{requesting-comment}}"})]
(is (= "Task demo 11111111-1111-1111-1111-111111111111 build-host\n- Ship the CLI bridge"
prompt))
(is (string/includes? comment-prompt
"Comment demo 55555555-5555-5555-5555-555555555555 build-host"))
(is (string/includes? comment-prompt "- Target block"))
(is (string/includes? comment-prompt "- [[build-host]] summarize"))))
(testing "template lint fails on missing and unknown vars"
(let [missing-result (#'agent-command/validate-prompt-template
:task
"Task {{graph}} {{block-uuid}} {{agent-name}}")
unknown-result (#'agent-command/validate-prompt-template
:task
"Task {{graph}} {{block-uuid}} {{agent-name}} {{task-block-tree}} {{extra}}")]
(is (false? (:ok? missing-result)))
(is (= :missing-template-vars (get-in missing-result [:error :code])))
(is (= #{"task-block-tree"} (get-in missing-result [:error :vars])))
(is (false? (:ok? unknown-result)))
(is (= :unknown-template-vars (get-in unknown-result [:error :code])))
(is (= #{"extra"} (get-in unknown-result [:error :vars]))))))
(deftest test-prompt-template-reader-ignores-documentation-code-fences
(testing "template reader ignores variable documentation code fences"
(let [template (#'agent-command/prompt-template-from-block
:task
{:block/title agent-command/task-prompt-template-title
:block/children [{:block/title "```text\n'{{graph}}': Graph name\n'{{block-uuid}}': Block UUID\n'{{agent-name}}': AgentBridge name\n'{{task-block-tree}}': Task tree\n```"}
{:block/title "```text\nTask {{graph}} {{block-uuid}} {{agent-name}}\n{{task-block-tree}}\n```"}]})]
(is (= "Task {{graph}} {{block-uuid}} {{agent-name}}\n{{task-block-tree}}\n"
template)))))
(deftest test-agent-bridge-initializes-default-prompt-templates
(async done
(let [calls* (atom [])
page-uuid (uuid "33333333-3333-3333-3333-333333333333")]
(-> (p/with-redefs [transport/invoke
(fn [_cfg method args]
(swap! calls* conj [method args])
(case method
:thread-api/q
(let [[_ [query & _query-args]] args]
(cond
(= query agent-command/agent-bridge-registry-page-query)
(p/resolved [])
(= query agent-command/agent-bridge-prompt-template-blocks-query)
(p/resolved [])
:else
(p/rejected (ex-info "unexpected query"
{:query query}))))
:thread-api/apply-outliner-ops
(let [[_ ops _] args]
(if (= [[:create-page [agent-command/agent-bridge-registry-page {}]]] ops)
(p/resolved [agent-command/agent-bridge-registry-page page-uuid])
(p/resolved {:ok true})))
:thread-api/pull
(p/resolved {:db/id 300
:block/uuid page-uuid
:block/title agent-command/agent-bridge-registry-page})
(p/rejected (ex-info "unexpected invoke"
{:method method
:args args}))))]
(p/let [templates (agent-command/ensure-agent-bridge-prompt-templates!
{:root-dir "/tmp/logseq"}
"logseq_db_demo")]
(is (string/includes? (:task templates) "{{task-block-tree}}"))
(is (string/includes? (:comment templates) "{{requesting-comment}}"))
(let [insert-ops (->> @calls*
(filter #(= :thread-api/apply-outliner-ops (first %)))
(mapv (comp second second)))]
(is (= [[:create-page [agent-command/agent-bridge-registry-page {}]]]
(first insert-ops)))
(let [inserted-blocks (-> (second insert-ops) first second first)
root-blocks (filter #(nil? (:block/parent %)) inserted-blocks)
root-uuids (set (map :block/uuid root-blocks))
child-blocks (remove #(nil? (:block/parent %)) inserted-blocks)]
(is (= #{agent-command/task-prompt-template-title
agent-command/comment-prompt-template-title}
(set (map :block/title root-blocks))))
(is (= 8 (count inserted-blocks)))
(is (some #(string/includes? (:block/title %) "```text\n'{{graph}}'")
child-blocks))
(is (not-any? #(re-find #"- \{\{[A-Za-z0-9-]+\}\}:"
(:block/title %))
child-blocks))
(is (every? #(contains? root-uuids (second (:block/parent %))) child-blocks))))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally done)))))
(deftest test-agent-bridge-ignores-recycled-registry-page
(async done
(let [calls* (atom [])
recycled-page {:db/id 188
:block/uuid (uuid "22222222-2222-2222-2222-222222222222")
:block/name "agentbridge"
:block/title agent-command/agent-bridge-registry-page
:block/parent {:db/id 183
:logseq.property/deleted-at 1}}
live-page-uuid (uuid "33333333-3333-3333-3333-333333333333")
live-page {:db/id 300
:block/uuid live-page-uuid
:block/name "agentbridge"
:block/title agent-command/agent-bridge-registry-page}]
(-> (p/with-redefs [transport/invoke
(fn [_cfg method args]
(swap! calls* conj [method args])
(case method
:thread-api/q
(let [[_ [query & _query-args]] args]
(cond
(= query agent-command/agent-bridge-registry-page-query)
(p/resolved [recycled-page])
(= query agent-command/agent-bridge-prompt-template-blocks-query)
(p/resolved [])
:else
(p/rejected (ex-info "unexpected query"
{:query query}))))
:thread-api/apply-outliner-ops
(let [[_ ops _] args]
(if (= [[:create-page [agent-command/agent-bridge-registry-page {}]]] ops)
(p/resolved [agent-command/agent-bridge-registry-page live-page-uuid])
(p/resolved {:ok true})))
:thread-api/pull
(p/resolved live-page)
(p/rejected (ex-info "unexpected invoke"
{:method method
:args args}))))]
(p/let [templates (agent-command/ensure-agent-bridge-prompt-templates!
{:root-dir "/tmp/logseq"}
"logseq_db_demo")]
(is (string/includes? (:task templates) "{{task-block-tree}}"))
(is (some #(= [:thread-api/apply-outliner-ops
["logseq_db_demo"
[[:create-page [agent-command/agent-bridge-registry-page {}]]]
{}]]
%)
@calls*))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally done)))))
(deftest test-agent-bridge-repairs-template-root-without-code-block
(async done
(let [calls* (atom [])
page-uuid (uuid "33333333-3333-3333-3333-333333333333")
comment-template-uuid (uuid "44444444-4444-4444-4444-444444444444")
page {:db/id 300
:block/uuid page-uuid
:block/title agent-command/agent-bridge-registry-page}
broken-comment-template {:db/id 401
:block/uuid comment-template-uuid
:block/title agent-command/comment-prompt-template-title}]
(-> (p/with-redefs [transport/invoke
(fn [_cfg method args]
(swap! calls* conj [method args])
(case method
:thread-api/q
(let [[_ [query & _query-args]] args]
(cond
(= query agent-command/agent-bridge-registry-page-query)
(p/resolved [page])
(= query agent-command/agent-bridge-prompt-template-blocks-query)
(p/resolved [broken-comment-template])
:else
(p/rejected (ex-info "unexpected query"
{:query query}))))
:thread-api/apply-outliner-ops
(p/resolved {:ok true})
(p/rejected (ex-info "unexpected invoke"
{:method method
:args args}))))]
(p/let [templates (agent-command/ensure-agent-bridge-prompt-templates!
{:root-dir "/tmp/logseq"}
"logseq_db_demo")]
(is (string/includes? (:task templates) "{{task-block-tree}}"))
(is (string/includes? (:comment templates) "{{requesting-comment}}"))
(let [insert-ops (->> @calls*
(filter #(= :thread-api/apply-outliner-ops (first %)))
(mapv (comp second second)))
child-repair-op (first (filter #(= comment-template-uuid
(-> % first second second))
insert-ops))]
(is (some? child-repair-op))
(is (some #(string/includes? (:block/title %) "{{requesting-comment}}")
(-> child-repair-op first second first))))))
(p/catch (fn [e]
(is false (str "unexpected error: " e))))
(p/finally done)))))
(deftest test-agent-bridge-template-initialization-lints-existing-page
(async done
(let [bad-template-block {:db/id 401
:block/title agent-command/task-prompt-template-title
:block/children [{:db/id 402
:block/title "```text\nTask {{graph}} {{block-uuid}} {{agent-name}} {{unknown}}\n```"}]}
page {:db/id 300
:block/uuid (uuid "33333333-3333-3333-3333-333333333333")
:block/title agent-command/agent-bridge-registry-page}]
(-> (p/with-redefs [transport/invoke
(fn [_cfg method args]
(case method
:thread-api/q
(let [[_ [query & _query-args]] args]
(cond
(= query agent-command/agent-bridge-registry-page-query)
(p/resolved [page])
(= query agent-command/agent-bridge-prompt-template-blocks-query)
(p/resolved [bad-template-block])
:else
(p/rejected (ex-info "unexpected query"
{:query query}))))
:thread-api/apply-outliner-ops
(p/resolved {:ok true})
(p/rejected (ex-info "unexpected invoke"
{:method method
:args args}))))]
(agent-command/ensure-agent-bridge-prompt-templates!
{:root-dir "/tmp/logseq"}
"logseq_db_demo"))
(p/then (fn [_]
(is false "expected template lint to fail")))
(p/catch (fn [e]
(is (= :agent-prompt-template-invalid
(:code (ex-data e))))
(is (= :task
(:template (ex-data e))))))
(p/finally done)))))
(deftest test-agent-bridge-listener-routes-comment-mention-with-context-and-reactions
(async done
(let [root (temp-root)
@@ -886,6 +1148,11 @@
agent-command/register-agent-bridge! (fn [cfg repo agent-name]
(swap! calls conj [:register (:root-dir cfg) repo agent-name])
(p/resolved true))
agent-command/ensure-agent-bridge-prompt-templates!
(fn [cfg repo]
(swap! calls conj [:prompt-templates (:root-dir cfg) repo])
(p/resolved {:task "Custom task {{graph}} {{block-uuid}} {{agent-name}}\n{{task-block-tree}}"
:comment "Custom comment {{graph}} {{comment-uuid}} {{agent-name}}\n{{comment-target-context}}\n{{comment-thread-context}}\n{{requesting-comment}}"}))
agent-command/list-routable-tasks (fn [_cfg repo agent-name]
(swap! calls conj [:list repo agent-name])
(p/resolved [{:block (task-block {})
@@ -899,10 +1166,15 @@
(is (= :ok (:status result)))
(is (= :dry-run (get-in result [:data :mode])))
(is (= [[:ensure-server "/tmp/logseq" "logseq_db_demo"]
[:prompt-templates "/tmp/logseq" "logseq_db_demo"]
[:register "/tmp/logseq" "logseq_db_demo" "build-host"]
[:list "logseq_db_demo" "build-host"]]
@calls))
(is (= 1 (count (get-in result [:data :commands]))))
(is (string/includes? (get-in result [:data :commands 0 :command 3])
"Custom task demo"))
(is (not (string/includes? (get-in result [:data :commands 0 :command 3])
"You are handling a Logseq AgentBridge task.")))
(is (string/includes? (first (get-in result [:data :logs]))
"checking the environment"))
(is (string/includes? (last (get-in result [:data :logs]))
@@ -926,6 +1198,11 @@
agent-command/register-agent-bridge! (fn [_cfg repo agent-name]
(swap! calls conj [:register repo agent-name])
(p/resolved true))
agent-command/ensure-agent-bridge-prompt-templates!
(fn [_cfg repo]
(swap! calls conj [:prompt-templates repo])
(p/resolved {:task "Task {{graph}} {{block-uuid}} {{agent-name}}\n{{task-block-tree}}"
:comment "Comment {{graph}} {{comment-uuid}} {{agent-name}}\n{{comment-target-context}}\n{{comment-thread-context}}\n{{requesting-comment}}"}))
agent-command/list-routable-tasks (fn [_cfg repo agent-name]
(swap! calls conj [:list repo agent-name])
(p/resolved [{:block block
@@ -948,13 +1225,10 @@
(is (= :ok (:status result)))
(is (= :processed-once (get-in result [:data :mode])))
(is (= [[:ensure-server root "logseq_db_demo"]
[:prompt-templates "logseq_db_demo"]
[:register "logseq_db_demo" "build-host"]
[:list "logseq_db_demo" "build-host"]
[:codex ["codex" "exec" "--json" (agent-command/build-codex-prompt
{:graph "demo"
:agent-name "build-host"
:block block
:tree-text "- Ship the CLI bridge"})]]
[:codex ["codex" "exec" "--json" "Task demo 11111111-1111-1111-1111-111111111111 build-host\n- Ship the CLI bridge"]]
[:ensure-server root "logseq_db_demo"]
[:write-session "logseq_db_demo" (:block/uuid block) "session-123"]]
@calls))