diff --git a/cli-e2e/scripts/agent_bridge_e2e.py b/cli-e2e/scripts/agent_bridge_e2e.py index 2f936050db..2f17014006 100644 --- a/cli-e2e/scripts/agent_bridge_e2e.py +++ b/cli-e2e/scripts/agent_bridge_e2e.py @@ -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) diff --git a/cli-e2e/spec/non_sync_cases.edn b/cli-e2e/spec/non_sync_cases.edn index 9157610758..bb60d1a195 100644 --- a/cli-e2e/spec/non_sync_cases.edn +++ b/cli-e2e/spec/non_sync_cases.edn @@ -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 diff --git a/src/main/logseq/cli/command/agent.cljs b/src/main/logseq/cli/command/agent.cljs index 21075d9d52..22cf4585de 100644 --- a/src/main/logseq/cli/command/agent.cljs +++ b/src/main/logseq/cli/command/agent.cljs @@ -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})))))))))))) diff --git a/src/test/logseq/cli/command/agent_test.cljs b/src/test/logseq/cli/command/agent_test.cljs index 131d971e44..85456b10bb 100644 --- a/src/test/logseq/cli/command/agent_test.cljs +++ b/src/test/logseq/cli/command/agent_test.cljs @@ -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))