Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/RFCS/RFC-002-manifests-and-cells.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ glob-scoped interceptors match on.
; [branch-kw pattern guard], or a (fn [data] pred) form
:constraints [{:type :must-follow :if node :then node}]
:subworkflows {cell-id manifest-name} ; optional: a nested manifest as one node
; (routing only: the child's :prompt is not
; read, and compile warns if it has one)
:prompt "name" ; optional: prompt appended to the base
:turn-sliceable? false ; optional, default true — see below
:extends "manifest-name" ; optional: carry only what differs from it
Expand Down Expand Up @@ -280,6 +282,21 @@ workflow/compile-loop

## Known gaps

- **A file edit is checked, not soaked or replayed.** The `cell`, `manifest`
and `policy` tools run the whole protocol: compile, soak (cells), and the
held-out battery (RFC-014) before an edit goes live. A project's workflow
is also FILES under `.samizdat/`, and an edit made to one directly — by a
person, or by the agent's own `write_file` — is recorded as a version and
goes live on its next read once it passes that kind's validator: it must
read and compile (and a cell must load), but it is not soaked and not
replayed against the battery. That is deliberate: a person editing their
project's workflow is not gated by the agent's own safety net, and the
file tools' refusal to write outside the project applies as ever. What it
means for the agent is that the tools are the gated route and a direct
write is not; RFC-001 says a direct edit is honoured. The shipped
`resources/cells` are templates only once a project has files: editing
one changes nothing that runs, and is offered to the supervisor for
adoption (karamazov-95u5 observed the opposite before files mode).
- **Every declared invariant is enforced.** Every ordering rule a manifest
claims is declared in its `:invariants`, each saying what it `:protects`
and whether it is `:enforced`; the enforced ones are DERIVED into the
Expand Down
43 changes: 35 additions & 8 deletions resources/cells/loop.clj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
;; Naming is load-bearing: :llm/*, :tool/*, :journal/*, :gate/* are what
;; glob-scoped interceptors match on.
(ns cells.loop
(:require [mycelium.cell :as cell]
(:require [clojure.string :as str]
[mycelium.cell :as cell]
[samizdat.agent.compaction :as cmp]
[samizdat.agent.gates :as gates]
[samizdat.agent.instructions :as instr]
Expand Down Expand Up @@ -223,7 +224,9 @@
{:doc "The single boundary: at most one steer, chosen in priority, plus the
context block of shared artifacts and similar failures."
:effects [:db]
:requires []
;; The run's billed spend, read here because this cell may reach the db and
;; route may not (karamazov-lq57).
:requires [:conn :run-id :token-budget]
;; :settled is REQUIRED and is the invariant: only :gate/settle writes it,
;; so a manifest that reaches the arbiter without closing this turn's
;; predictions first — crediting a gate with an outcome that preceded it —
Expand All @@ -232,10 +235,19 @@
:input [:map [:settled :map] [:branch :map] [:turn :int]
[:parsed {:optional true} :any]
[:result {:optional true} :any]]
:output [:map [:branch :map]]}
(fn [ctx {:keys [branch turn parsed result] :as data}]
(assoc data :branch (turn/steer-step ctx branch turn
{:parsed parsed :result result}))))
:output [:map [:branch :map] [:over-budget? {:optional true} :boolean]]}
(fn [{:keys [conn run-id token-budget] :as ctx} {:keys [branch turn parsed result] :as data}]
(let [;; THE RUN'S TOKEN BUDGET, in every loop that runs a turn
;; (karamazov-lq57). It was read only at the beam's round boundary,
;; so a feature, team or decompose run — one "turn" to the beam —
;; spent without bound inside its nested loops. The sum is the
;; run's whole bill off the journal, the same number the beam
;; holds; a run with no budget pays for no query.
spent (when (and token-budget conn run-id)
(:total-tokens (journal/run-usage conn run-id)))]
(cond-> (assoc data :branch (turn/steer-step ctx branch turn
{:parsed parsed :result result}))
(and spent (>= spent token-budget)) (assoc :over-budget? true)))))

(cell/defcell :loop/route
{:doc "Decide the turn's verdict: :continue (next turn), :done, :abandoned,
Expand All @@ -244,7 +256,7 @@
branch and the configured cap — no side effects."
:pure true
:requires [:max-turns]
:input [:map [:branch :map] [:turn :int]]
:input [:map [:branch :map] [:turn :int] [:over-budget? {:optional true} :any]]
;; :turn as well as :verdict, because the :continue branch increments it.
;; The dissoc of the per-turn products is invisible to mycelium — it models
;; what a cell ADDS, never what it drops — and that is safe here only
Expand All @@ -268,12 +280,27 @@
(gates/threshold :provider-error-limit))
:abandoned

;; The run has spent its token budget (the arbiter read
;; it): every loop's branch ends, nested ones included.
(:over-budget? data) :exhausted

(>= turn max-turns) :exhausted
:else :continue)]
(cond-> (assoc data :verdict verdict)
;; An ending with NO reason is how an oversight pass came to record
;; `abandoned` with `ended` and `notes` both null (karamazov-n6ql): the
;; provider-error arm and an inactive branch with no answer set none.
;; Named here, where the verdict is decided, when nothing else did.
(and (= verdict :abandoned) (nil? (:inactive-reason branch)))
(assoc-in [:branch :inactive-reason]
(str/trim (prompt/render "abandoned-reason"
(if (state/active? branch)
{:provider-errors (:consecutive-provider-errors branch)}
{:status (some-> (:status branch) name)}))))

(= verdict :continue)
(-> (update :turn inc)
(dissoc :before :call :parsed :signals :said :result :tool :settled)
(dissoc :before :call :parsed :signals :said :result :tool :settled :over-budget?)
;; Each mycelium trace entry snapshots the whole data map — branch
;; message history included — so an uncapped trace grows
;; quadratically over a run. The journal is the durable record; the
Expand Down
10 changes: 10 additions & 0 deletions resources/gates.edn
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,16 @@
Raise it on a slow filesystem or a very large repo; 0 disables the
cache and reads on every request."}

:image-start
{:value {:attempts 2 :image-log-lines 20}
:provenance ["karamazov-69p0" "karamazov-tetz"]
:kind :policy :capability-tunable? false
:doc "How a project image is started. :attempts — tries, each on a fresh
port, before the eval fails: the free-port gap is a real race and a
load spike is not a reason to fail an eval. :image-log-lines — how
much of the child's own output a failed start logs, so a failure
says why rather than only where its profile is."}

:image-connect-ms
{:value 20000 :kind :threshold :capability-tunable? true
:provenance ["karamazov-zrq"]
Expand Down
1 change: 1 addition & 0 deletions resources/prompts/abandoned-reason.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% if provider-errors %}the provider failed {{provider-errors}} calls in a row{% else %}the branch stopped ({{status}}) without an answer{% endif %}
4 changes: 2 additions & 2 deletions resources/prompts/shell-refused.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ this one is fine:

{{blocked}}

`{{blockedhead}}` is not on the allow list. Reissue the command without that
{% if blockedform %}This form of `{{blockedhead}}` is not on the allow list — other forms of it are.{% else %}`{{blockedhead}}` is not on the allow list.{% endif %} Reissue the command without that
part, or use a tool that does the same job: `read_file` and `grep` to look
around, `eval` to run Clojure.
{% else %}{% if promoted %}
Expand All @@ -25,7 +25,7 @@ This is a COMPOUND command — it contains {{markers}} — so it is judged as on
whole claim rather than by its first word, and `{{head}}` is not allowed on its
own either. Split it up and check the parts.
{% else %}
`{{head}}` is not on the allow list, so it needs a human to grant it — and if
{% if headform %}This form of `{{head}}` is not on the allow list — other forms of it are — so it needs a human{% else %}`{{head}}` is not on the allow list, so it needs a human{% endif %} to grant it — and if
this run has no human watching, it will not be granted. Prefer a tool that does
the same job without the shell: `read_file` and `grep` to look around, `eval`
to run Clojure, including this project's own tests once you have required the
Expand Down
1 change: 1 addition & 0 deletions resources/prompts/task-claim-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Cannot {{verb}} `{{id}}`: {% if missing %}there is no task with that id. `task list` shows the board.{% endif %}{% if closed %}it is already {{status}}{% if yours %} — you closed it yourself{% endif %}. A closed task stays closed; if work is left, create a task for it rather than reopening this one.{% endif %}{% if held %}{% if other-run %}another run holds it{% else %}branch {{holder}} holds it{% endif %}. Pick another task, or leave this one to whoever is working it.{% endif %}
2 changes: 2 additions & 0 deletions resources/userspace.edn
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,15 @@

:prompts
{:acceptance-failed "prompts/acceptance-failed.md"
:abandoned-reason "prompts/abandoned-reason.md"
:acceptance-judge "prompts/acceptance-judge.md"
:adopt-tool "prompts/adopt-tool.md"
:battery-tool "prompts/battery-tool.md"
:heldout-decided "prompts/heldout-decided.md"
:heldout-pending "prompts/heldout-pending.md"
:heldout-refused "prompts/heldout-refused.md"
:refused-again "prompts/refused-again.md"
:task-claim-refused "prompts/task-claim-refused.md"
:tests-not-run "prompts/tests-not-run.md"
:adoption-offer "prompts/adoption-offer.md"
:architect "prompts/architect.md"
Expand Down
14 changes: 14 additions & 0 deletions resources/wordlists.edn
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@
"clpfd" "prolog" "smt" "lean" "works" "available" "loaded" "basic"
"supports" "simple" "test" "check" "verify" "verified" "example"}

:truncated-finish-reasons
;; finish_reason values that mean the reply was cut short, not finished: the
;; fix is more tokens or a retry, never steering (fence/signals :truncated).
;; DeepSeek sends insufficient_system_resource when it stops generating
;; under load (karamazov-6uyv).
#{"length" "insufficient_system_resource"}

:surviving-findings
;; The heading a verify pass puts over the findings it kept (karamazov-kdoj).
;; When a reply has one, only what is under it is read as findings: the
;; reasoning above it quotes candidates' severity tags and used to be stored
;; as the findings and handed to the retry.
#{"surviving findings" "findings that survive" "remaining findings"}

:request-framing
;; Words that frame a REQUEST rather than name its subject — what to do and
;; where to say it. The problem-relevance rung drops them from the PROBLEM's
Expand Down
10 changes: 6 additions & 4 deletions src/samizdat/agent/beam.clj
Original file line number Diff line number Diff line change
Expand Up @@ -693,10 +693,12 @@
(reset! started pending)
(reduce (fn [acc p] (conj acc (settle p))) [] pending)))
(catch Throwable e
;; The round itself was cancelled (an abort) with turns in flight:
;; every turn goes down with it before the signal travels on.
(when (cancel/control-signal? e)
(doseq [[_ t] @started :when (map? t)] ((:cancel t))))
;; The round failed with turns in flight — an abort, or anything else
;; that broke the wait: every turn it started goes down with it
;; before the throw travels on. Only a cancel signal used to, so any
;; other failure left spawned turns running past the driver that
;; owned them (karamazov-odyx).
(doseq [[_ t] @started :when (map? t)] ((:cancel t)))
(throw e)))))

(defn dispose-branch-engines!
Expand Down
34 changes: 28 additions & 6 deletions src/samizdat/agent/judge.clj
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
judge is told to say has to touch both."
(:require [clojure.string :as str]
[samizdat.agent.gates :as gates]
[samizdat.lexicon :as lexicon]
[samizdat.llm.message :as message]
[samizdat.prompt :as prompt]
[samizdat.util :as util]))
Expand Down Expand Up @@ -444,14 +445,31 @@
f))))

(defn- severity-line?
"Whether `line` opens a finding: it carries one of gates.edn
:review-severities as a bracketed tag."
"Whether `line` opens a finding: it STARTS with one of gates.edn
:review-severities as a bracketed tag, after an optional bullet, number or
emphasis. Anywhere in the line was too loose: a reasoning paragraph quoting
a candidate's `[low]` became a finding and carried the prose after it
(karamazov-kdoj)."
[line]
(let [sevs (gates/threshold :review-severities)]
(boolean (and (seq sevs)
(re-find (re-pattern (str "(?i)\\[(" (str/join "|" sevs) ")\\]"))
(re-find (re-pattern (str "(?i)^\\s*(?:[-*+]|\\d+[.)])?\\s*\\**\\[("
(str/join "|" sevs) ")\\]"))
(str line))))))

(defn- surviving-section
"The part of a verify reply under its surviving-findings heading (wordlists
:surviving-findings), or the reply whole when it has none."
[reply]
(let [heads (lexicon/wordlist :surviving-findings)
lines (str/split-lines (str reply))
head? (fn [l] (let [t (str/lower-case (str/trim (str/replace (str l) #"[#*:]" "")))]
(contains? (set heads) t)))
after (rest (drop-while (complement head?) lines))]
(if (and (seq heads) (some head? lines))
(str/trim (str/join "\n" after))
reply)))

(defn finding-segments
"A findings text split into one segment per finding.

Expand Down Expand Up @@ -569,8 +587,10 @@
(clean-pass? reply) nil
(str/blank? (str reply)) (dedupe-findings candidates)
:else
(let [segs (finding-segments reply)
dropped (count (filterv false-positive? segs))
(let [;; Only what the judge listed as surviving, when it listed it:
;; the deliberation above that heading is not findings.
segs (finding-segments (surviving-section reply))
dropped (count (filterv false-positive? (finding-segments reply)))
;; A SURVIVOR HAS TO BE A FINDING. Filtering only on
;; false-positive? let any prose the judge emitted through as the
;; findings text — "Hmm, hard to say." would have REPLACED two real
Expand All @@ -579,7 +599,9 @@
;; narration, and narration never fabricates a finding.
kept (->> segs
(remove false-positive?)
(filter #(severity-line? (first (str/split-lines %)))))
;; Its first NON-BLANK line: a segment that opens on a
;; blank line is still the finding under it.
(filter #(severity-line? (first (remove str/blank? (str/split-lines %))))))
survivors (not-empty (str/trim (str/join kept)))]
(cond
survivors (dedupe-findings survivors)
Expand Down
23 changes: 19 additions & 4 deletions src/samizdat/agent/tools/tasks.clj
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@
" list, show {id}, update {id, ...fields}, claim {id},"
" switch {id, reason}, close {id, status?}."))

(defn- claim-refused
"Why `claim!` returned nil, from the task's own row: there is none, it is
closed (and by this branch), or someone else holds it. One sentence used to
cover all three, so a branch that had closed its own task read that another
run held it, and made a duplicate (karamazov-fjrq)."
[conn id run-id branch verb]
(let [row (tasks/get-task conn id)
closed? (and row (or (:closed_at row) (#{"done" "cancelled"} (str (:status row)))))]
(prompt/render "task-claim-refused"
(cond
(nil? row) {:verb verb :id id :missing true}
closed? {:verb verb :id id :closed true :status (:status row)
:yours (= (str (:branch_id row)) (str (:id branch)))}
:else {:verb verb :id id :held true
:other-run (not= (str (:run_id row)) (str run-id))
:holder (:branch_id row)}))))

(defmethod base/run-tool "task" [{:keys [branch conn run-id] :as ctx}]
;; Every action is `ok` (:neutral) on purpose: working the board is
;; bookkeeping, and bookkeeping is not progress — the same reasoning as
Expand Down Expand Up @@ -176,8 +193,7 @@
(base/ok (take-task branch t)
(str "Claimed " (task-line t))
:progress? true)
(base/malformed branch (str "Cannot claim " (base/arg ctx :id)
": no such task, or another run holds it.")))))
(base/malformed branch (claim-refused conn (base/arg ctx :id) run-id branch "claim")))))

"switch"
(or (want :id) (want :reason)
Expand Down Expand Up @@ -215,8 +231,7 @@
(task-line t)
"\nRecorded why: " reason)
:progress? true))
(base/malformed branch (str "Cannot switch to " (base/arg ctx :id)
": no such task, or another run holds it."))))))
(base/malformed branch (claim-refused conn (base/arg ctx :id) run-id branch "switch to"))))))

"close"
(or (want :id)
Expand Down
7 changes: 6 additions & 1 deletion src/samizdat/llm/fence.clj
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,12 @@
fix is more tokens, not more steering. It was the first thing a live
deepseek-v4-flash call did here, so it is not a hypothetical."
[{:keys [finish-reason content]} parsed]
(let [truncated (= "length" finish-reason)
(let [;; Every finish reason that means the reply was CUT SHORT rather than
;; finished (wordlists :truncated-finish-reasons): `length` everywhere,
;; and DeepSeek's `insufficient_system_resource`, which ended a reply
;; mid-generation and read as a no-call (karamazov-6uyv).
truncated (contains? (or (lexicon/wordlist :truncated-finish-reasons) #{"length"})
(str finish-reason))
;; A reply repeating itself, checked only where it matters — a
;; truncated reply, or one that made no call — so a long healthy
;; reply that reached its fence is not scanned (karamazov-o4wm.5).
Expand Down
14 changes: 12 additions & 2 deletions src/samizdat/manifests.clj
Original file line number Diff line number Diff line change
Expand Up @@ -692,10 +692,20 @@
;; see them where they already look for :undeclared-effects.
(let [unguarded (for [c (unguarded-cycles definition)]
(assoc c :type :unguarded-cycle))
;; COMPOSITION IS ROUTING-ONLY (karamazov-r6x1): a composed child
;; runs as a node of its parent, under the parent's prompt, and its
;; own :prompt is never read. Said, rather than silently dropped.
composed-prompts (for [[cell-id mname] (:subworkflows definition)
:let [child (try (read-definition (manifest-body! mname))
(catch Throwable _ nil))]
:when (:prompt child)]
{:type :composed-prompt-ignored :cell-id cell-id
:manifest mname :prompt (:prompt child)})
warnings (concat unguarded composed-prompts)
compiled (cond-> compiled
(and (seq unguarded) (:compiled-fsm compiled))
(and (seq warnings) (:compiled-fsm compiled))
(update-in [:compiled-fsm :mycelium/compile-warnings]
(fnil into []) unguarded))]
(fnil into []) warnings))]
(when-let [warnings (:mycelium/compile-warnings (:compiled-fsm compiled))]
(log/warn "loop definition compiled with warnings:" (pr-str warnings)))
compiled))))
Expand Down
Loading
Loading