From b92095ccaf1831d32a9641327bfc41b3489947a1 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 3 Jul 2026 12:58:06 +0000 Subject: [PATCH] =?UTF-8?q?agentic-sx=20Phase=203:=20trace=20=E2=80=94=20c?= =?UTF-8?q?onsole=20output=20as=20attached=20CID=20objects=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-agent buffer = persist append-only log stream + kv drain cursor; commit-with-trace! drains everything-since-last-commit into a console-trace object and binds it git-note style (ref notes/trace/ -> trace cid). Trace never enters the commit tree; binding is a re-bindable ref layer over immutable objects; failed commits keep the buffer; plain commit! leaves binding to the agent. 35/35 (153/153 total). Co-Authored-By: Claude Fable 5 --- lib/agentic/conformance.sh | 2 +- lib/agentic/scoreboard.json | 7 +- lib/agentic/scoreboard.md | 3 +- lib/agentic/tests/trace.sx | 289 ++++++++++++++++++++++++++++++++++++ lib/agentic/trace.sx | 136 +++++++++++++++++ 5 files changed, 432 insertions(+), 5 deletions(-) create mode 100644 lib/agentic/tests/trace.sx create mode 100644 lib/agentic/trace.sx diff --git a/lib/agentic/conformance.sh b/lib/agentic/conformance.sh index ee4703c4..85d34fad 100755 --- a/lib/agentic/conformance.sh +++ b/lib/agentic/conformance.sh @@ -13,7 +13,7 @@ if [ ! -x "$SX_SERVER" ]; then exit 1 fi -SUITES=(schema branch) +SUITES=(schema branch trace) OUT_JSON="lib/agentic/scoreboard.json" OUT_MD="lib/agentic/scoreboard.md" diff --git a/lib/agentic/scoreboard.json b/lib/agentic/scoreboard.json index b31b6764..7946cc0f 100644 --- a/lib/agentic/scoreboard.json +++ b/lib/agentic/scoreboard.json @@ -1,9 +1,10 @@ { "suites": { "schema": {"pass": 65, "fail": 0}, - "branch": {"pass": 53, "fail": 0} + "branch": {"pass": 53, "fail": 0}, + "trace": {"pass": 35, "fail": 0} }, - "total_pass": 118, + "total_pass": 153, "total_fail": 0, - "total": 118 + "total": 153 } diff --git a/lib/agentic/scoreboard.md b/lib/agentic/scoreboard.md index 51e5da1b..e5f23d16 100644 --- a/lib/agentic/scoreboard.md +++ b/lib/agentic/scoreboard.md @@ -6,4 +6,5 @@ _Generated by `lib/agentic/conformance.sh`_ |-------|-----:|-----:|------:| | schema | 65 | 0 | 65 | | branch | 53 | 0 | 53 | -| **Total** | **118** | **0** | **118** | +| trace | 35 | 0 | 35 | +| **Total** | **153** | **0** | **153** | diff --git a/lib/agentic/tests/trace.sx b/lib/agentic/tests/trace.sx new file mode 100644 index 00000000..fde8681b --- /dev/null +++ b/lib/agentic/tests/trace.sx @@ -0,0 +1,289 @@ +; Phase 3 — trace: console output as attached content-addressed objects. +; Fixture story: tracer-1 logs console/tool entries and commits with traces +; (drain-at-commit granularity); quiet-1 stays silent and gets a manual +; genesis trace attached + rebound; a failed commit keeps the buffer; a +; plain commit! deliberately leaves the buffer alone (agent-chosen binding). + +(define agt-db (persist/mem-backend)) +(define agt-sp (agentic/space agt-db "agentic-trace-test")) +(define agt-repo (agentic/space-repo agt-sp)) +(define + agt-a + (agentic/spawn! + agt-sp + "tracer-1" + (agentic/briefing "trace things" "exercise the trace layer" {}))) +(define + agt-b + (agentic/spawn! + agt-sp + "quiet-1" + (agentic/briefing "stay quiet" "no console output" {}))) + +(agentic-test + "fresh agent has an empty buffer" + (= (agentic/trace-pending agt-sp "tracer-1") (list)) + true) +(agentic-test + "trace! appends to the buffer" + (agentic/trace! agt-sp "tracer-1" "console" "$ compiling") + true) + +(agentic/trace! agt-sp "tracer-1" "tool" "sx_eval (+ 1 2)") + +(agentic-test + "pending sees logged entries" + (len (agentic/trace-pending agt-sp "tracer-1")) + 2) +(agentic-test + "pending preserves log order" + (get (nth (agentic/trace-pending agt-sp "tracer-1") 0) :text) + "$ compiling") +(agentic-test + "buffers are per agent" + (= (agentic/trace-pending agt-sp "quiet-1") (list)) + true) + +; ---- commit drains the buffer into an attached trace ---- +(define + agt-c1 + (agentic/commit-with-trace! + agt-sp + "tracer-1" + "finding" + (assoc {} "notes.md" "found it\n") + {:message "first finding"})) + +(agentic-test + "commit-with-trace! commits" + (starts-with? (get agt-c1 :cid) "sx1:") + true) +(agentic-test + "commit-with-trace! attaches a trace" + (starts-with? (get agt-c1 :trace) "sx1:") + true) +(agentic-test + "commit advances the head" + (= (agentic/head agt-sp "tracer-1") (get agt-c1 :cid)) + true) +(agentic-test + "trace-for finds the bound trace" + (agentic/console-trace? (agentic/trace-for agt-sp (get agt-c1 :cid))) + true) +(agentic-test + "bound trace carries the entries" + (len (agentic/trace-entries (agentic/trace-for agt-sp (get agt-c1 :cid)))) + 2) +(agentic-test + "bound trace keeps entry order" + (get + (nth + (agentic/trace-entries (agentic/trace-for agt-sp (get agt-c1 :cid))) + 0) + :text) + "$ compiling") +(agentic-test + "trace names its commit by cid" + (get (agentic/trace-for agt-sp (get agt-c1 :cid)) :commit) + (get agt-c1 :cid)) +(agentic-test + "trace names its agent" + (get (agentic/trace-for agt-sp (get agt-c1 :cid)) :agent) + "tracer-1") +(agentic-test + "trace is NOT in the commit tree" + (= + (git/tree-names + (git/read + agt-repo + (git/commit-tree (git/read agt-repo (get agt-c1 :cid))))) + (list "notes.md")) + true) +(agentic-test + "buffer drained after commit" + (= (agentic/trace-pending agt-sp "tracer-1") (list)) + true) + +; ---- granularity = the commit: only entries since the last drain travel ---- +(agentic/trace! agt-sp "tracer-1" "console" "$ second round") +(define + agt-c2 + (agentic/commit-with-trace! + agt-sp + "tracer-1" + "refactor" + (assoc {} "notes.md" "refined\n") + {:message "second"})) + +(agentic-test + "next trace carries only new entries" + (len (agentic/trace-entries (agentic/trace-for agt-sp (get agt-c2 :cid)))) + 1) +(agentic-test + "next trace text" + (get + (nth + (agentic/trace-entries (agentic/trace-for agt-sp (get agt-c2 :cid))) + 0) + :text) + "$ second round") +(agentic-test + "earlier trace is unchanged" + (len (agentic/trace-entries (agentic/trace-for agt-sp (get agt-c1 :cid)))) + 2) + +; ---- a silent commit binds nothing ---- +(define + agt-c3 + (agentic/commit-with-trace! + agt-sp + "tracer-1" + "decision" + (assoc {} "notes.md" "done\n") + {:message "silent"})) + +(agentic-test "silent commit has no trace key" (has-key? agt-c3 :trace) false) +(agentic-test + "silent commit still commits" + (= (agentic/head agt-sp "tracer-1") (get agt-c3 :cid)) + true) +(agentic-test + "trace-for nil on a traceless commit" + (agentic/trace-for agt-sp (get agt-c3 :cid)) + nil) + +; ---- attachment is external to the object layer ---- +(agentic-test + "attached commit round-trips to the same cid" + (= (git/cid (git/read agt-repo (get agt-c1 :cid))) (get agt-c1 :cid)) + true) +(agentic-test + "trace object is content-addressed" + (= + (get agt-c1 :trace) + (git/cid + (agentic/console-trace + (list + (agentic/trace-entry "console" "$ compiling") + (agentic/trace-entry "tool" "sx_eval (+ 1 2)")) + {:agent "tracer-1" :commit (get agt-c1 :cid)}))) + true) + +(define + agt-manual + (agentic/attach-trace! + agt-sp + (get agt-b :genesis) + (agentic/console-trace + (list (agentic/trace-entry "console" "spawn log")) + {:commit (get agt-b :genesis)}))) + +(agentic-test + "manual attach to any commit" + (starts-with? agt-manual "sx1:") + true) +(agentic-test + "manual attachment is found" + (= (agentic/trace-cid-for agt-sp (get agt-b :genesis)) agt-manual) + true) +(agentic-test + "attach validates the object type" + (get + (agentic/attach-trace! + agt-sp + (get agt-b :genesis) + (agentic/briefing "x" "y" {})) + :error) + "not-a-console-trace") + +(define + agt-manual2 + (agentic/attach-trace! + agt-sp + (get agt-b :genesis) + (agentic/console-trace + (list (agentic/trace-entry "console" "amended log")) + {:commit (get agt-b :genesis)}))) + +(agentic-test + "re-attach rebinds the note ref" + (= (agentic/trace-cid-for agt-sp (get agt-b :genesis)) agt-manual2) + true) +(agentic-test + "rebinding keeps the old object in the store" + (agentic/console-trace? (git/read agt-repo agt-manual)) + true) + +; ---- session-wide view ---- +(agentic-test + "session-traces pairs commits with traces, newest first" + (= + (agentic/session-traces agt-sp "tracer-1") + (list + (list (get agt-c2 :cid) (get agt-c2 :trace)) + (list (get agt-c1 :cid) (get agt-c1 :trace)))) + true) +(agentic-test + "session-traces sees manual genesis attachments" + (= + (agentic/session-traces agt-sp "quiet-1") + (list (list (get agt-b :genesis) agt-manual2))) + true) + +; ---- failed commits keep the buffer ---- +(agentic/trace! agt-sp "tracer-1" "console" "$ doomed") +(define + agt-bad + (agentic/commit-with-trace! + agt-sp + "tracer-1" + "frobnicate" + {} + {})) + +(agentic-test + "failed commit passes the error through" + (get agt-bad :error) + "unknown-kind") +(agentic-test + "failed commit keeps the buffer" + (len (agentic/trace-pending agt-sp "tracer-1")) + 1) + +(define + agt-c4 + (agentic/commit-with-trace! + agt-sp + "tracer-1" + "test" + (assoc {} "notes.md" "recovered\n") + {:message "recover"})) + +(agentic-test + "kept entries travel with the next commit" + (get + (nth + (agentic/trace-entries (agentic/trace-for agt-sp (get agt-c4 :cid))) + 0) + :text) + "$ doomed") + +; ---- binding is agent-chosen: plain commit! leaves the buffer alone ---- +(agentic/trace! agt-sp "tracer-1" "console" "$ held back") +(define + agt-c5 + (agentic/commit! + agt-sp + "tracer-1" + "decision" + (assoc {} "notes.md" "plain\n") + {:message "plain"})) + +(agentic-test + "plain commit! binds nothing" + (agentic/trace-for agt-sp agt-c5) + nil) +(agentic-test + "plain commit! leaves the buffer" + (len (agentic/trace-pending agt-sp "tracer-1")) + 1) \ No newline at end of file diff --git a/lib/agentic/trace.sx b/lib/agentic/trace.sx new file mode 100644 index 00000000..d36af938 --- /dev/null +++ b/lib/agentic/trace.sx @@ -0,0 +1,136 @@ +; lib/agentic/trace.sx — agentic-sx Phase 3: console traces as ATTACHED +; content-addressed objects. An agent's console/tool output accumulates in a +; per-agent append-only persist log stream; the commit verb drains everything +; since the last commit into a console-trace object and binds it to the new +; commit git-note style: ref "notes/trace/" -> trace cid. The +; trace is NOT in the commit's tree — attaching never changes the commit cid, +; and the note is a re-bindable ref layer over immutable objects. +; Granularity = the commit, agent-chosen: whatever was logged since the last +; drain travels with the next commit. +; Requires: lib/agentic/branch.sx (and its deps). + +; ---- buffer stream + drain cursor (namespaced under the repo prefix) ---- +(define + agentic/trace-stream + (fn + (sp agent) + (str (get (agentic/space-repo sp) :prefix) "/trace/" agent))) + +(define + agentic/trace-cursor-key + (fn + (sp agent) + (str (get (agentic/space-repo sp) :prefix) "/trace-cursor/" agent))) + +; append one console/tool entry to the agent's buffer => true +(define + agentic/trace! + (fn + (sp agent kind text) + (begin + (persist/append + (git/repo-db (agentic/space-repo sp)) + (agentic/trace-stream sp agent) + "trace-entry" + 0 + (agentic/trace-entry kind text)) + true))) + +; entries logged since the last drain, oldest first +(define + agentic/trace-pending + (fn + (sp agent) + (let + ((db (git/repo-db (agentic/space-repo sp)))) + (let + ((cur (persist/kv-get db (agentic/trace-cursor-key sp agent)))) + (map + (fn (e) (persist/event-data e)) + (persist/read-from + db + (agentic/trace-stream sp agent) + (+ (if (nil? cur) 0 cur) 1))))))) + +; advance the drain cursor to the stream's high-water mark +(define + agentic/trace-mark! + (fn + (sp agent) + (let + ((db (git/repo-db (agentic/space-repo sp)))) + (begin + (persist/kv-put + db + (agentic/trace-cursor-key sp agent) + (persist/last-seq db (agentic/trace-stream sp agent))) + true)))) + +; ---- git-note-style binding: commit cid -> trace cid ---- +(define + agentic/trace-note-ref + (fn (commit-cid) (str "notes/trace/" commit-cid))) + +; write the trace object and bind it to the commit => trace cid | {:error} +(define + agentic/attach-trace! + (fn + (sp commit-cid trace-obj) + (let + ((repo (agentic/space-repo sp))) + (if + (not (agentic/console-trace? trace-obj)) + {:error "not-a-console-trace"} + (let + ((tcid (git/write repo trace-obj))) + (begin + (git/ref-set! repo (agentic/trace-note-ref commit-cid) tcid) + tcid)))))) + +(define + agentic/trace-cid-for + (fn + (sp commit-cid) + (git/ref-get (agentic/space-repo sp) (agentic/trace-note-ref commit-cid)))) + +(define + agentic/trace-for + (fn + (sp commit-cid) + (let + ((tcid (agentic/trace-cid-for sp commit-cid))) + (if (nil? tcid) nil (git/read (agentic/space-repo sp) tcid))))) + +; ---- the commit verb with trace binding ---- +; commit! then drain the buffer into an attached console-trace. +; => {:cid cid :trace tcid} | {:cid cid} when nothing was logged +; | commit!'s {:error ...}/{:conflict ...} passthrough (buffer kept) +(define + agentic/commit-with-trace! + (fn + (sp agent kind files meta) + (let + ((cid (agentic/commit! sp agent kind files meta))) + (if + (dict? cid) + cid + (let + ((entries (agentic/trace-pending sp agent))) + (if + (= (len entries) 0) + {:cid cid} + (let + ((tcid (agentic/attach-trace! sp cid (agentic/console-trace entries {:agent agent :commit cid})))) + (begin (agentic/trace-mark! sp agent) {:trace tcid :cid cid})))))))) + +; (commit-cid trace-cid) pairs for the agent's session, newest first, +; commits without a bound trace omitted +(define + agentic/session-traces + (fn + (sp agent) + (filter + (fn (p) (not (nil? (nth p 1)))) + (map + (fn (cid) (list cid (agentic/trace-cid-for sp cid))) + (agentic/session-log sp agent))))) \ No newline at end of file