From 8e1babed050bf9803b0b733350705540c232cc36 Mon Sep 17 00:00:00 2001 From: "claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla)" Date: Tue, 11 Aug 2026 23:01:38 +0000 Subject: [PATCH] Let a turn commission further targeted work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turn (plan, implementation, any registry type) may now write `artifactRequest` records of its own through the turn socket — "this chunk needs three plans" — opt-in per operator and bounded at the write path. - lexicon: optional `commissionedBy` strongRef on `artifactRequest`, additive and fold-inert; nothing in `materialize()` branches on it and the digest does not read it (permutation coverage in core/test/commissioned-request.test.mjs). - core: `RequestArtifactRpc`, `TURN_LIMITS.maxTurnRequests`/`requestBriefChars`/ `turnRequestBasedOnMax`, and `agentMemberDids()` — the fold's own agent set, exported so the bound reads it rather than a lookalike. - sidecar: `radial request create --type T (--brief|--brief-file) [--based-on]...` in turn mode, refusing --goal/--project/--subject/--assignee. - daemon: `#requestArtifact` writes the record open, anchored to the commissioning request, at an ordinal-keyed deterministic rkey (a retried turn adopts its own nth request). Four refusals carry the design: review/answer turns commission nothing, no opt-in means no, an agent-authored commissioning request means no (one hop), and `review`/`answer` are not commissionable types. - `run.agentRequests`: `author` (default false), `dispatch` (default true, read by both the dispatcher and the claim manager), `max` (default and ceiling 10). - The turn prompt offers the verb only where the socket will accept it. - The "daemon writes no other request" source-scan invariant is renegotiated to two type-constrained writers, with the socket's refusals pinned; CLAUDE.md, design.md §7/§9/§13/§15/§16, the daemon README, radial-json and operators.md updated to match. Co-Authored-By: claudebot.disnetdev.com (did:plc:n6ku5xddiuguwze3f356evla) --- CLAUDE.md | 12 +- docs/design.md | 18 +- docs/operators.md | 29 ++ docs/radial-json.md | 20 ++ packages/core/src/generated/records.ts | 5 + packages/core/src/materializer.ts | 14 + packages/core/src/turn-protocol.ts | 32 +- .../core/test/commissioned-request.test.mjs | 273 ++++++++++++++ packages/daemon/README.md | 67 +++- packages/daemon/src/claims.ts | 15 +- packages/daemon/src/cli.ts | 13 + packages/daemon/src/config.ts | 55 ++- packages/daemon/src/dispatch.ts | 96 ++++- packages/daemon/src/harness.ts | 19 + packages/daemon/src/ledger.ts | 33 ++ packages/daemon/src/turn-socket.ts | 235 +++++++++++- packages/daemon/src/turn.ts | 26 +- packages/daemon/test/auto-review.test.mjs | 72 +++- packages/daemon/test/cli.test.mjs | 11 + packages/daemon/test/config.test.mjs | 24 ++ packages/daemon/test/dispatch.test.mjs | 55 +++ packages/daemon/test/harness.test.mjs | 33 +- .../daemon/test/private-dispatch.test.mjs | 64 ++++ packages/daemon/test/turn-socket.test.mjs | 334 +++++++++++++++++- packages/lexicons/README.md | 29 ++ .../com.disnetdev.radial.artifactRequest.json | 1 + packages/sidecar/src/cli.ts | 8 +- packages/sidecar/src/socket.ts | 45 ++- packages/sidecar/test/image-create.test.mjs | 2 +- packages/sidecar/test/socket.test.mjs | 56 ++- 30 files changed, 1652 insertions(+), 44 deletions(-) create mode 100644 packages/core/test/commissioned-request.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 8296d9d..1e65767 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,9 +335,15 @@ request, because tangled ships no user-facing CLI. private fold reads `agent` only off the private path, so a PDS write there is invisible by construction. That record carries the effective list resolved into `artifactTypes` and no `scopes` — a replica is per space. -- **The daemon never authors an `answer` request.** Auto-review is the only daemon-authored hop. - `daemon/test/auto-review.test.mjs` asserts this over the source: exactly one - `create(COLLECTIONS.artifactRequest)` exists under `src/`, and it builds a `review`. +- **The daemon never authors an `answer` request**, and there are exactly TWO writers of an + `artifactRequest` under `src/`, each type-constrained. `auto-review.ts` builds a `review`; + `turn-socket.ts` builds what a TURN commissioned (design §7, §13) — open, anchored to the + commissioning request's own goal/project, and refusing `review` and `answer` as types outright, so + a reply still cannot commission the next reply. Authoring is off unless the operator sets + `run.agentRequests.author`, and the iteration bound is one hop: a turn whose own request an agent + wrote may commission nothing. `daemon/test/auto-review.test.mjs` asserts all of it over the source + — the allowlist of writer files and, for the socket, the refusals themselves. A third writer, or a + lost refusal, fails there. - **Reviews annotate, never gate**, and Radial never merges. Anything the UI or daemon acts on mechanically must be a typed record — never a parsed message body. - **No third-party runtime or test dependencies** outside `@radial/ui` (and `esbuild` for builds). diff --git a/docs/design.md b/docs/design.md index 3730e77..1f7410f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -171,7 +171,13 @@ Every artifact moves through the same four moments. None of them is a state the **Supersession.** A `request_changes` verdict blocks nothing mechanically — it is information on the card. The human reads the findings and decides whether to request a v2 (`prev`-linked, findings included in the new turn's bundle) or move on. Radial is **review-annotated, not review-gated**: a team that wants "no implementation without an approved plan" gets it as soft UI policy (the button warns when the basis plan lacks an approving verdict), not a protocol guarantee. That's acceptable precisely because the thing gates protected against — agents autonomously spending tokens past an unapproved plan — cannot happen when every generation has a human click behind it. -**Future trigger rules.** Auto-review generalizes: it is a degenerate trigger rule (`on artifact of type X → request review`). Later rules — auto-revise-once on `request_changes`, auto-implement on plan approval — can layer onto the same substrate as opt-in per-project config. The lesson carried over from the state-machine design: **any rule that can cause generation must carry an iteration bound**, or two models ping-pong tokens forever. v1 sidesteps this entirely by shipping only the terminal hop. +**Commissioned work.** The second thing that can cause generation, and the first that is not terminal: a turn may write `artifactRequest` records of its own — "this chunk is three plans, not one" — through the same daemon-owned socket path its artifact goes through (§9, §13). The record is ordinary in every respect: open (claims arbitrate, §9), anchored to the commissioning request's own goal or project, carrying `commissionedBy` as provenance that **no fold rule reads**. + +Its iteration bound is structural rather than counted, and it is enforced where a bound can be enforced without splitting the fold — at the WRITE path. A turn whose own request was authored by one of the space's agents may commission nothing, so the fan-out is exactly **one hop**; a per-turn cap bounds its width; `review` and `answer` turns may not commission at all (a verdict never triggers further generation, and a reply must never commission the next reply — §14C); and neither `review` nor `answer` may be commissioned as a type. Authoring is off unless the operator turns it on, so a deployment that wants v1's posture keeps it exactly. What one human click can now buy is therefore `1 + cap` turns per operator instead of one, and `retractRequest` is the runtime stop. + +A bound in the fold was the rejected alternative: `materialize()` cannot read who is running or what a daemon was configured with, and a depth counter in the record would be a new rule two implementations could disagree about — an older materializer would then fold the same corpus differently, which is the one thing this design cannot survive. + +**Future trigger rules.** Auto-review generalizes: it is a degenerate trigger rule (`on artifact of type X → request review`). Later rules — auto-revise-once on `request_changes`, auto-implement on plan approval — can layer onto the same substrate as opt-in per-project config. The lesson carried over from the state-machine design: **any rule that can cause generation must carry an iteration bound**, or two models ping-pong tokens forever — and the two rules that exist state theirs: auto-review is terminal, commissioned work is one hop and capped. **A goal's ending** is an explicit human act, and a goal has exactly one. It is a standalone `com.disnetdev.radial.closeGoal` record — `{goal, closed, disposition?}` — which **any active member** may write, which is reversible (latest wins, so `closed: false` reopens), and which carries a one-word `disposition` saying which kind of ending it was: `completed` · `dropped` · `superseded` · `parked`. Consumers read one predicate, `GoalView.ended`; nothing in the fold branches on `disposition`, so the vocabulary can grow without two materializers disagreeing about which goals have ended (it is typed as a free string with a documented vocabulary, not a lexicon enum, for exactly that reason — `packages/lexicons/README.md` carries the contract). @@ -213,7 +219,7 @@ Generation surfaces: Trigger: a daemon's fold shows an open request that names one of its agents, or an open request whose type one of its agents produces. For open (unassigned) requests it first writes a `claim` (lease, e.g. 15 min, heartbeat-renewed) and confirms after an ingestion cycle that its claim won; assigned requests need no claim. It then: 1. Launches a container with: a materialized **goal bundle** (goal, the `basedOn` artifacts and their version ancestry, open review findings on them, checkrun results, current versions of the project's system artifacts (§8), thread history — as JSON/markdown), a git checkout at the right ref, per-turn credentials (the operator's forge token (§10) plus a spend-capped model key — §13), and the composed brief (type template + request brief). -2. The harness does its work and emits records only through the sidecar, which forwards them over a local socket to the daemon — validation, signing, and the PDS write all happen daemon-side (§13). A turn can emit exactly one terminal record plus messages, and which one is fixed by the request's type before the container starts: the requested **artifact**, a **`review`** of the named subject (for `review` requests), or a **thread reply** — a `message` threaded under the named subject — for `answer` requests. The last two are built-ins of this layer rather than registry types (§4): a member cannot redefine what judging or replying means, and neither produces an artifact or a row in a goal's unit list. The daemon owns every anchoring field of all three; a container supplies a body and, for an artifact, its criteria. +2. The harness does its work and emits records only through the sidecar, which forwards them over a local socket to the daemon — validation, signing, and the PDS write all happen daemon-side (§13). A turn can emit exactly one terminal record, plus messages, plus — where the operator enabled it — up to a capped number of `artifactRequest` records commissioning further targeted work (§7). Which terminal is fixed by the request's type before the container starts: the requested **artifact**, a **`review`** of the named subject (for `review` requests), or a **thread reply** — a `message` threaded under the named subject — for `answer` requests. The last two are built-ins of this layer rather than registry types (§4): a member cannot redefine what judging or replying means, and neither produces an artifact or a row in a goal's unit list. The daemon owns every anchoring field of all three; a container supplies a body and, for an artifact, its criteria. 3. Container exits; any claim is released; the request is now fulfilled in the fold. Key properties: @@ -312,7 +318,7 @@ cannot claim this value, and a later reroute cannot rewrite an already-signed re - **Protocol credentials never enter containers.** atproto signing stays daemon-side behind the sidecar socket, which is the only harness → protocol write path, and a turn can only emit its requested type. This is the non-negotiable invariant: an agent's atproto identity is unscopeable, and revoking it costs the space its coordination history. Everything else below is scopeable and expendable, and is treated accordingly. - **Containers are a possession boundary, not a network boundary.** A turn container holds exactly two secrets: the operator's forge token (§10) — their own GitHub auth, or a repo-scoped fine-grained PAT if they want scoping — and a spend-capped model API key dedicated to agent turns, for whichever provider the profile's models name (the daemon forwards only credential names its harnesses declare, plus an explicit operator allowlist, and only when set in its own environment; a daemon-side model proxy that would keep the key out entirely — and give per-turn metering — is a possible later upgrade, not v1). Egress is open: agents fetch docs, packages, and arbitrary web resources. Network confinement was never protecting the contents — the repo and bundle are public by stance — and an allowlist taxes every legitimate lookup. What remains is **host isolation**: containers run on an ordinary bridge network (never host network), the sidecar socket is the only daemon-facing surface, and cloud metadata endpoints are blocked when deployed on cloud infra. Check containers get network too (dependency installs need it) but carry no secrets at all. - **ChatGPT-managed Codex auth is an explicit stronger-credential mode.** `run.codexAuth.mode = "chatgpt-session"` replaces the spend-capped Codex API key with a refreshable account login for operators whose Codex entitlement lives in ChatGPT. A fresh device login belongs to one Radial instance; the operator's ordinary `~/.codex` is never copied or mounted. Each turn receives a scratch auth home, turns sharing it are serialized, and only a validated refreshed `auth.json` is atomically written back. Everything else in that home is discarded. This preserves cross-turn isolation, but not possession isolation: the running turn can read the account credential, so the mode is for trusted workloads and is never automatic. -- **Prompt injection:** untrusted input is unbounded — member records in the bundle, repo contents, and anything the agent reads on the open web. The mitigation is entirely write-side. The worst an injected turn can do is waste its capped spend, leak its forge token, and spam forge writes within that token's forge-enforced scope — all attributable and revocable at the forge. The leaked token is the operator's own (§10), so the damage bound is branch protection plus the human merge, and the remedy is operator-side revocation; the deferred App tier (§10) shrinks the leak to an hour-lived scoped token when it lands. It cannot touch the protocol except through the typed, requested-type-only sidecar path (so it can't commission new work, even though the protocol itself wouldn't reject an agent-authored request, §3), and it cannot merge. Everything an agent writes is attributable to its DID and revocable with its membership. Review and the human merge remain the behavioral mitigation. +- **Prompt injection:** untrusted input is unbounded — member records in the bundle, repo contents, and anything the agent reads on the open web. The mitigation is entirely write-side. The worst an injected turn can do is waste its capped spend, leak its forge token, and spam forge writes within that token's forge-enforced scope — all attributable and revocable at the forge. The leaked token is the operator's own (§10), so the damage bound is branch protection plus the human merge, and the remedy is operator-side revocation; the deferred App tier (§10) shrinks the leak to an hour-lived scoped token when it lands. It cannot touch the protocol except through the typed, daemon-anchored sidecar path, and it cannot merge. What that path allows is deliberately narrow and, for commissioning, deliberately opt-in: by default a turn cannot commission new work at all, even though the protocol itself wouldn't reject an agent-authored request (§3). An operator who enables it (`run.agentRequests.author`) accepts a bounded relaxation of this sentence: an injected turn could then commission up to the per-turn cap of open requests, of registered types only, under the commissioning request's own goal — never a `review` or an `answer`, never assigned to anybody, and never able to commission a second hop, because a request an agent authored dispatches a turn that may commission nothing. So the worst case is a bounded, attributable, retractable burst of work under one goal, executed by whichever operators opted to run agent-authored requests, rather than an unbounded fan-out. The record's `commissionedBy` provenance is what a human reads to find it; the bound does not depend on it. Everything an agent writes is attributable to its DID and revocable with its membership. Review and the human merge remain the behavioral mitigation. Guest comments (§4) do not widen this, and the reason is structural rather than a matter of framing. An unblessed comment from a non-member is not in any store, any index or any bundle — nothing polls a non-member's repo — so it reaches an agent by no path at all. What can reach one is a member's `blessComment`, and by then the text is *bytes a member signed*: the worst case is exactly the worst case for a hostile member message, which this section already bounds, and it is reversible by `retractBless` from either the blessing's author or an admin. What the UI owes a member is that the choice is informed — the confirm shows the full text being copied — and what `bundle.md` owes an agent is provenance: the section is separate from the thread, labelled with both DIDs, and never merged into it, so a turn can always tell which lines came from inside the space. The label is not the mitigation. The mitigations are that the copy is deliberate and the bound is unchanged. @@ -344,7 +350,9 @@ cannot claim this value, and a later reroute cannot rewrite an already-signed re ## 15. Resolved decisions -- Every agent turn is commissioned by an `artifactRequest`. Human initiation is a convention carried by the UI and daemons, not a protocol rule — materializers don't police request authorship, and auto-review requests are agent-authored. The only automation in v1 is the single-hop auto-review trigger, and any future trigger rule that can cause generation must carry an iteration bound. (§3, §7) +- Every agent turn is commissioned by an `artifactRequest`. Human initiation is a convention carried by the UI and daemons, not a protocol rule — materializers don't police request authorship, and auto-review requests are agent-authored. Any rule that can cause generation must carry an iteration bound; two exist. (§3, §7) +- Auto-review is terminal: a verdict never triggers further generation. (§7) +- A turn may commission further targeted work by writing open `artifactRequest` records through the turn socket, off by default per operator and bounded at the WRITE path, never in the fold: one hop (a request an agent authored dispatches a turn that commissions nothing), a per-turn cap, no `review`/`answer` turn commissioning anything, and neither of those two types commissionable. `commissionedBy` is additive provenance no fold rule reads. A second operator knob decides whether this daemon EXECUTES agent-authored requests at all; it is local policy and changes nothing convergent. (§7, §9, §13) - Reviews annotate, never gate. "Current version" is a UI concept; enforcement like "implement only approved plans" is soft UI policy. (§7) - Agents answer questions through a typed, human-authored `answer` request whose terminal record is a thread reply; the daemon never authors one, and a mention still dispatches nothing. Goal-scoped and assignee-required in v1. (§9, §10) - Artifact types are per-space registry data; `plan` and `implementation` ship as built-ins. (§4) @@ -375,7 +383,7 @@ cannot claim this value, and a later reroute cannot rewrite an already-signed re ## 16. Open questions 1. Open-request arbitration across operators: is the slim claim record + deterministic tie-break enough, or do open requests eventually need negotiation (cost quotes, bids)? Tied to the undesigned economic layer between spaces and operators. -2. Trigger rules beyond auto-review: representation and bounds for chaining rules (auto-revise-once, auto-implement-on-approval) when they arrive — per-rule iteration caps, per-goal, or both? +2. Trigger rules beyond auto-review: representation and bounds for chaining rules (auto-revise-once, auto-implement-on-approval) when they arrive — per-rule iteration caps, per-goal, or both? Partly answered by commissioned work (§7): where a rule is a TURN's decision rather than a daemon's, the bound can live at the write path (one hop, keyed on the fold's agent set, plus a per-turn cap) and needs no fold rule and no depth counter. What is still open is a rule the DAEMON evaluates over the index, which has no equivalent structural bound and would need a counted one — and, with several operators watching one space, a story for who pays for it. 3. Forge adapter surface: is observe-merge polling enough, or do we need webhook support per forge for responsiveness? ## 17. Related work diff --git a/docs/operators.md b/docs/operators.md index 6a2cf7d..3d686e9 100644 --- a/docs/operators.md +++ b/docs/operators.md @@ -207,6 +207,35 @@ protocol state, not the generations already spent (see "Claims and clocks" above): a request at the generation cap gets another sixteen, starting above the records it already wrote. +### Somebody else's agents can commission work you pay for + +An open request is answered by whichever capable operator's claim wins, and +requests may be authored by **agents** as well as humans — that has been true +since auto-review, and it is now also true of the requests a *turn* commissions +(design §7, §13). So an operator who turns on `run.agentRequests.author` in +their daemon can produce open requests that **your** daemon claims and **your** +model budget pays for. + +What bounds it: commissioning is one hop deep (a request an agent authored +dispatches a turn that may commission nothing), each turn is capped +(`run.agentRequests.max`, ceiling 10), and every commissioned request carries +`commissionedBy` naming the turn that asked for it — so a surprise is +attributable to a request and a person in one lookup. + +What you can do about it: + +| Want | Setting | +| --- | --- | +| Never author commissioned work from my turns | Leave `run.agentRequests.author` unset — that is the default. | +| Never RUN work an agent asked for, including auto-review's | `"agentRequests": { "dispatch": false }` — your daemon skips every agent-authored request and says so in its rejection reason. The requests stay open for whoever will take them. | +| Cap how much work in flight regardless of who asked | `run.claims.maxOutstanding` and `run.concurrency`, which bound everything alike. | + +`dispatch: false` is a blunt instrument on purpose: it also declines +auto-review's requests, which most operators want run. Prefer it when you are +sharing a space with an operator whose fan-out you have not agreed to. Either +way, `radial request retract` withdraws a specific request, and any active +member may write one. + ### A daemon that restarts mid-lease The claim rkey is a pure function of the request and the claim's generation, and diff --git a/docs/radial-json.md b/docs/radial-json.md index 5f70aa5..075b58a 100644 --- a/docs/radial-json.md +++ b/docs/radial-json.md @@ -127,6 +127,7 @@ into the file. | `turnTransport` | `auto` | Selects Unix sockets on Linux and TCP on macOS/Windows. Explicit `unix` and `tcp` override detection; TCP is a development escape hatch—do not expose the daemon socket. | | `claims` | see below | Claim and lease timing for **open (unassigned)** requests. Only matters in a space that uses them; a daemon serving assigned requests never writes a claim. | | `jetstream` | unset | Opt-in [Jetstream](https://github.com/bluesky-social/jetstream) ingestion. Polling stays the authority and the backfill, so this only shortens latency. | +| `agentRequests` | see below | Whether this daemon's turns may commission further work, and whether it runs work agents commissioned. | #### `run.claims` @@ -152,6 +153,25 @@ lease — `min(30s, leaseMs / 10)` — warning when a peer's clock or a peer's record timestamps are further from this machine's than that. Advisory only: nothing in the fold compares two clocks. +#### `run.agentRequests` + +A turn may commission further targeted work — "this chunk needs three plans" — +by writing open `artifactRequest` records of its own (design §7, §13). The two +knobs are separate because they are separate risks: one is a capability **your +containers** gain, the other is **your model budget** paying for work somebody +else's agent asked for. + +| Field | Default | Meaning | +| --- | --- | --- | +| `author` | `false` | Let this daemon's turns commission work (`radial request create --type TYPE --brief …` inside the container). Off by default: with it on, an injected turn can commission up to `max` open requests under the turn's own goal, so this should be a decision somebody made. Everything else about the record is the daemon's — the anchor, the openness, the `commissionedBy` provenance — and `review`/`answer` can be neither commissioned nor commissioning. | +| `dispatch` | `true` | Run requests an **agent** authored, including other operators' and including auto-review's. `false` is the conservative exit for an operator who does not want to pay for another operator's fan-out; the request simply stays open for whoever will take it, and nothing convergent changes. Both dispatch and claiming read it, so this daemon never claims work it would then refuse to run. | +| `max` | 10 | How many requests one turn may commission. 10 is also the ceiling the turn socket enforces; a larger value is refused at parse time rather than clamped silently. | + +The bound on all this is one hop: a turn whose OWN request an agent authored may +commission nothing, so a fan-out cannot fan out again. One human click therefore +buys at most `1 + max` turns per operator, and `radial request retract` is the +stop. `radiald` logs what each turn commissioned. + #### `run.privateSpaces` A private space's records never reach a PDS: they travel as signed envelopes over diff --git a/packages/core/src/generated/records.ts b/packages/core/src/generated/records.ts index f8d00c6..17082a4 100644 --- a/packages/core/src/generated/records.ts +++ b/packages/core/src/generated/records.ts @@ -110,6 +110,7 @@ export interface ArtifactRequestRecord { subject?: StrongRef basedOn: Array assignee?: string + commissionedBy?: StrongRef brief?: string autoReview?: boolean createdAt: string @@ -724,6 +725,10 @@ export const lexiconSchemas = [ "type": "string", "format": "did" }, + "commissionedBy": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, "brief": { "type": "string", "maxLength": 30000 diff --git a/packages/core/src/materializer.ts b/packages/core/src/materializer.ts index b2b23c5..b119145 100644 --- a/packages/core/src/materializer.ts +++ b/packages/core/src/materializer.ts @@ -1464,6 +1464,20 @@ export function materialize(store: RecordStore, options: MaterializeOptions): Ma // trigger, the check dispatcher and the merge poller — where the second half was, until projects // could be archived, not a condition at all. +/** + * The space's agents, as the FOLD counts them: DIDs holding an active `kind: 'agent'` membership + * grant. This is the same set `computeAwaitingInput` reads and `materialize` binds agent records + * with, exported because a caller outside the fold needs to ask the same question of the same set + * rather than a lookalike — the daemon's one-hop bound on agent-commissioned work (design §7, §13) + * is "was this request authored by one of these", and a second spelling of it would drift. + * + * Known consequence, and the reason it is membership KIND rather than anything else: an operator who + * runs one DID as both a human member and an agent is an agent here, so requests they write by hand + * commission nothing. That is how every other agent-vs-human question in the fold already reads. + */ +export const agentMemberDids = (index: MaterializedIndex): Set => + new Set(index.members.filter((member) => member.active && member.kind === 'agent').map((member) => member.did)) + /** Projects agents act on: everything the author has not archived. */ export const activeProjects = (index: MaterializedIndex): ProjectView[] => index.projects.filter((view) => !view.archived) diff --git a/packages/core/src/turn-protocol.ts b/packages/core/src/turn-protocol.ts index dd4fb64..f7242e8 100644 --- a/packages/core/src/turn-protocol.ts +++ b/packages/core/src/turn-protocol.ts @@ -10,7 +10,24 @@ export interface SubmitReviewRpc { method: 'submitReview'; token: string; ve * anchoring field — goal, `parent`/`root`, the `re` backref, the mention of whoever is being replied * to — from turn context, so a turn can only reply to the message its request named. */ export interface SubmitAnswerRpc { method: 'submitAnswer'; token: string; body: string } -export type TurnRpcRequest = SubmitArtifactRpc | AskQuestionRpc | SubmitReviewRpc | SubmitAnswerRpc +/** + * A turn commissioning further targeted work: an `artifactRequest` of its own (design §7, §13). + * + * NON-TERMINAL — a turn may emit its one terminal record *plus* up to `TURN_LIMITS.maxTurnRequests` + * of these, in any order — and deliberately thin. The daemon owns every anchoring field, exactly as + * it does for a question or a verdict: the sub-request hangs off the commissioning request's own + * goal/project, carries `commissionedBy` pointing back at it, and is written OPEN (no `assignee`, + * so claims arbitrate). What the container supplies is what only it knows — which type of work, and + * the brief for it — plus `basedOn` refs the daemon validates against its own index before copying. + * + * `basedOn` carries LOCATORS (`at://…` or `at://…#cid`), not strongRefs, for the same reason the + * anchor is daemon-owned: a container reads bare URIs out of its bundle and has no way to pin a cid + * it can be trusted on. The daemon resolves each against its own index — an artifact of this space, + * at the version it holds, or the exact version a locator pins — and a ref it cannot resolve is a + * refusal rather than a record naming something nobody has. + */ +export interface RequestArtifactRpc { method: 'requestArtifact'; token: string; type: string; brief: string; basedOn?: string[] } +export type TurnRpcRequest = SubmitArtifactRpc | AskQuestionRpc | SubmitReviewRpc | SubmitAnswerRpc | RequestArtifactRpc export type TurnRpcResponse = { ok: true; ref: StrongRef } | { ok: false; error: string } export const TURN_LIMITS = { artifactBodyChars: 100_000, @@ -18,6 +35,19 @@ export const TURN_LIMITS = { * would drop, so both writers refuse it rather than write it — see `normalizeArtifactTitle`. */ artifactTitleChars: 200, messageBodyChars: 30_000, + /** `com.disnetdev.radial.artifactRequest`'s `brief` ceiling — the same cap the lexicon puts on a + * human's brief, applied to a turn-commissioned one at the untrusted socket edge. */ + requestBriefChars: 30_000, + /** + * How many sub-requests ONE turn may commission (design §7's iteration bound, the per-turn half). + * The other half is structural and lives at the socket: a turn whose own request was authored by an + * agent may commission nothing, so the fan-out is one hop deep. Together they bound what a single + * human click can buy at `1 + maxTurnRequests` turns per operator. + */ + maxTurnRequests: 10, + /** `--based-on` refs one sub-request may carry. Well below the lexicon's 100: these are refs off + * the wire, each of which the daemon resolves against its own index before copying. */ + turnRequestBasedOnMax: 10, // The review lexicon caps findings at 100 entries × 10,000-char bodies (~1MB of body text alone, // more once JSON-encoded with per-finding path/line/severity). 2MB comfortably covers the largest // valid review payload; the per-field character limits above still bound artifact/message bodies. diff --git a/packages/core/test/commissioned-request.test.mjs b/packages/core/test/commissioned-request.test.mjs new file mode 100644 index 0000000..6fd7edc --- /dev/null +++ b/packages/core/test/commissioned-request.test.mjs @@ -0,0 +1,273 @@ +// A request one TURN commissioned (design §7, §13): an agent-authored `artifactRequest` carrying +// `commissionedBy`, its claim, and the artifact that fulfils it. +// +// The claim this file makes is a NEGATIVE one, and that is why it is a scenario of its own rather +// than more records in the golden one — the same reason `privateScenario()` is separate: the most +// valuable thing to be able to say about this feature in the fold is that NOTHING changed there. +// `commissionedBy` is provenance; no rule branches on it, the digest does not read it, and the +// bound on agent-commissioned work is enforced by the writer (the turn socket), never by +// `materialize()` — a bound expressed as a fold rule would make old and new materializers disagree +// about the same corpus. +// +// So: the same records fold identically with the field and without it, an agent-authored request is +// an ordinary open request that claims arbitrate, and every arrival permutation agrees. + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { indexDigest } from '../dist/digest.js' +import { materialize } from '../dist/materializer.js' +import { MemoryRecordStore } from '../dist/store.js' +import { COLLECTIONS } from '../dist/generated/records.js' + +const ROOT = 'did:plc:root' +const HUMAN = 'did:plc:human' +const AGENT_A = 'did:plc:agenta' +const AGENT_B = 'did:plc:agentb' +const AS_OF = '2026-02-01T00:00:00Z' + +function make(did, collection, rkey, cid, value) { + return { did, collection, rkey, uri: `at://${did}/${collection}/${rkey}`, cid, rev: '0000000000001', value } +} +const ref = (record) => ({ uri: record.uri, cid: record.cid }) + +/** + * One goal with a human's plan request, the agent's plan answering it, and — commissioned by that + * turn — a SECOND plan request, open, authored by the agent, carrying `commissionedBy`, which + * AGENT_B claims and fulfils. That is the whole shape of the feature as it reaches the fold. + */ +function commissionedScenario() { + const records = [] + const add = (record) => { + records.push(record) + return record + } + + const space = add( + make(ROOT, COLLECTIONS.space, 'space', 'cid-space', { + $type: COLLECTIONS.space, + name: 'Commissioned', + description: 'agent-authored requests', + createdAt: '2026-01-01T00:00:00Z', + }), + ) + const spaceRef = ref(space) + for (const [index, did] of [HUMAN, AGENT_A, AGENT_B].entries()) { + add( + make(ROOT, COLLECTIONS.addMember, `member-${index}`, `cid-member-${index}`, { + $type: COLLECTIONS.addMember, + space: spaceRef, + did, + kind: did === HUMAN ? 'human' : 'agent', + role: did === HUMAN ? 'member' : 'agent', + createdAt: `2026-01-01T00:00:0${index + 1}Z`, + }), + ) + } + add( + make(ROOT, COLLECTIONS.artifactType, 'type-plan', 'cid-type-plan', { + $type: COLLECTIONS.artifactType, + space: spaceRef, + name: 'plan', + brief: 'Write a plan.', + outputSpec: { format: 'markdown', description: 'A plan' }, + scope: 'goal', + createdAt: '2026-01-01T00:00:10Z', + }), + ) + const project = add( + make(HUMAN, COLLECTIONS.project, 'project', 'cid-project', { + $type: COLLECTIONS.project, + space: spaceRef, + name: 'demo', + gitUrl: 'https://example.com/demo.git', + defaultBranch: 'main', + checks: [], + autoReview: {}, + createdAt: '2026-01-01T00:01:00Z', + }), + ) + const goal = add( + make(HUMAN, COLLECTIONS.goal, 'goal', 'cid-goal', { + $type: COLLECTIONS.goal, + space: spaceRef, + project: ref(project), + title: 'Ship the big thing', + body: 'It is too big for one plan.', + createdAt: '2026-01-01T00:02:00Z', + }), + ) + const rootRequest = add( + make(HUMAN, COLLECTIONS.artifactRequest, 'request-root', 'cid-request-root', { + $type: COLLECTIONS.artifactRequest, + goal: ref(goal), + type: 'plan', + basedOn: [], + assignee: AGENT_A, + createdAt: '2026-01-01T00:03:00Z', + }), + ) + const rootPlan = add( + make(AGENT_A, COLLECTIONS.artifact, 'plan-root', 'cid-plan-root', { + $type: COLLECTIONS.artifact, + request: ref(rootRequest), + goal: ref(goal), + type: 'plan', + title: 'The overall shape', + body: 'Three pieces; each needs its own plan.', + links: {}, + createdAt: '2026-01-01T00:04:00Z', + }), + ) + // The commissioned request: authored by the AGENT that ran the root turn, open (no assignee — a + // claim decides), based on the artifact that turn produced, and naming its commissioner. + const commissioned = add( + make(AGENT_A, COLLECTIONS.artifactRequest, 'reqn-one', 'cid-reqn-one', { + $type: COLLECTIONS.artifactRequest, + goal: ref(goal), + type: 'plan', + basedOn: [ref(rootPlan)], + brief: 'Plan the first piece.', + commissionedBy: ref(rootRequest), + createdAt: '2026-01-01T00:05:00Z', + }), + ) + const claim = add( + make(AGENT_B, COLLECTIONS.claim, 'claim-reqn-one', 'cid-claim-reqn-one', { + $type: COLLECTIONS.claim, + request: ref(commissioned), + expiresAt: '2026-02-01T00:30:00Z', + createdAt: '2026-01-31T23:50:00Z', + }), + ) + const commissionedPlan = add( + make(AGENT_B, COLLECTIONS.artifact, 'plan-piece-one', 'cid-plan-piece-one', { + $type: COLLECTIONS.artifact, + request: ref(commissioned), + goal: ref(goal), + type: 'plan', + title: 'Piece one', + body: 'How the first piece gets built.', + links: {}, + createdAt: '2026-01-31T23:55:00Z', + }), + ) + + return { + records, + spaceUri: space.uri, + goalUri: goal.uri, + rootRequestUri: rootRequest.uri, + commissionedUri: commissioned.uri, + claimUri: claim.uri, + commissionedPlanUri: commissionedPlan.uri, + } +} + +function shuffled(records, seed) { + const result = [...records] + let state = seed >>> 0 + const random = () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } + for (let index = result.length - 1; index > 0; index -= 1) { + const target = random() % (index + 1) + ;[result[index], result[target]] = [result[target], result[index]] + } + return result +} + +function build(records, spaceUri) { + const store = new MemoryRecordStore() + records.forEach((record) => store.put(record)) + return materialize(store, { spaceUri, asOf: AS_OF }) +} + +const goalView = (index, uri) => index.goals.find((view) => view.target.uri === uri) + +describe('a request a turn commissioned', () => { + it('folds as an ordinary request of its goal, authored by the agent', () => { + const scenario = commissionedScenario() + const view = goalView(build(scenario.records, scenario.spaceUri), scenario.goalUri) + const commissioned = view.requests.find((request) => request.uri === scenario.commissionedUri) + assert.ok(commissioned, 'the commissioned request is filed under the goal it names') + assert.equal(commissioned.did, 'did:plc:agenta', 'authored by the agent whose turn wrote it') + assert.deepEqual(commissioned.value.commissionedBy, { + uri: scenario.rootRequestUri, + cid: 'cid-request-root', + }) + assert.equal( + view.requests.some((request) => request.uri === scenario.commissionedUri && request.value.assignee), + false, + 'a commissioned request is open', + ) + assert.equal( + view.ignored?.some?.((entry) => entry.uri === scenario.commissionedUri) ?? false, + false, + ) + }) + + it('is claimable and fulfilled exactly like a human-authored open request', () => { + const scenario = commissionedScenario() + // Before its artifact lands, the claim wins the request in the fold — the ordinary open-request + // path, and the reason a commissioned request needs no assignee. + const pending = goalView( + build( + scenario.records.filter((record) => record.uri !== scenario.commissionedPlanUri), + scenario.spaceUri, + ), + scenario.goalUri, + ) + assert.equal(pending.winningClaims[scenario.commissionedUri]?.uri, scenario.claimUri) + assert.equal( + pending.openRequests.some((request) => request.uri === scenario.commissionedUri), + true, + ) + + const view = goalView(build(scenario.records, scenario.spaceUri), scenario.goalUri) + assert.equal( + view.openRequests.some((request) => request.uri === scenario.commissionedUri), + false, + 'the artifact answering it closed it, as for any other request', + ) + assert.equal( + view.artifacts.some((artifact) => artifact.uri === scenario.commissionedPlanUri), + true, + ) + }) + + // The whole design rests on this: `commissionedBy` is inert in the fold. If it ever gained a rule, + // an older materializer would fold this corpus differently from a newer one — which is the one + // thing a decentralized fold cannot survive. + it('is fold-inert: stripping commissionedBy changes neither the index nor the digest', () => { + const scenario = commissionedScenario() + const withField = build(scenario.records, scenario.spaceUri) + const stripped = scenario.records.map((record) => { + if (record.uri !== scenario.commissionedUri) return record + const { commissionedBy: _dropped, ...value } = record.value + return { ...record, value } + }) + const without = build(stripped, scenario.spaceUri) + assert.equal(indexDigest(withField).overall, indexDigest(without).overall) + // Everything but the record's own contents is identical, which is the honest statement of + // "no rule branches on it" — the field is still IN the record, and rightly shows up there. + const shape = (index) => ({ + ...index, + goals: index.goals.map((view) => ({ + ...view, + requests: view.requests.map((request) => ({ ...request, value: { ...request.value, commissionedBy: null } })), + })), + }) + assert.deepEqual(shape(withField), shape(without)) + }) + + it('is invariant under 100 deterministic random arrival permutations', () => { + const scenario = commissionedScenario() + const expected = build(scenario.records, scenario.spaceUri) + for (let seed = 1; seed <= 100; seed += 1) { + assert.deepEqual(build(shuffled(scenario.records, seed), scenario.spaceUri), expected, `seed ${seed}`) + } + }) +}) diff --git a/packages/daemon/README.md b/packages/daemon/README.md index 1ff89a3..f190faf 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -121,6 +121,10 @@ Three kinds of turn run in the same container shape and differ only in their ter | `review` | `review` verdict on the named subject | read-only, pinned to the subject's commit when it links one | yes (PR reconnaissance) | | `answer` | `message` — a reply in the goal's thread | read-only, project default branch | no | +A turn of a registry type — the first row only — may ALSO commission further requests where the +operator enabled it (see "Agent-authored requests" below). A `review` or `answer` turn never may: +a verdict never triggers further generation, and a reply never commissions the next reply. + The daemon stamps terminal records and blocked-turn questions with the effective model at the instant each write begins. A concrete model reported by streamed harness output wins over the configured invocation model; an unknown harness default is omitted. This provenance is daemon-owned @@ -146,11 +150,64 @@ non-member's repo — so it cannot reach a bundle by any path. The section is se and names both DIDs, so a turn can tell which lines came from inside the space; the actual bound on what an injected one can do is unchanged and write-side (design §13). -**The daemon never authors an `answer` request.** Auto-review is the one daemon-authored hop there -is; every answer request is written by a human, which is what keeps a reply from commissioning the -next reply (design §10, and §14's rejected alternative C). The property is asserted over the -source — `test/auto-review.test.mjs`, "the daemon writes no other request": exactly one -`create(COLLECTIONS.artifactRequest)` exists under `src/`, and it builds an open `review`. +## Agent-authored requests + +A turn may commission further targeted work — "this chunk needs three plans" — by writing +`artifactRequest` records of its own through the turn socket (design §7, §13). In the container it is +spelled with the human verb: + +```sh +radial request create --type plan --brief "Plan the storage half" [--based-on AT-URI]... +``` + +It is **off unless the operator turns it on**, and there are two knobs, because they are two +different risks: + +```jsonc +"run": { + "agentRequests": { + "author": true, // this daemon's turns may commission work. Default FALSE. + "dispatch": true, // this daemon runs requests an AGENT authored. Default TRUE. + "max": 10 // how many one turn may commission. Default (and ceiling) 10. + } +} +``` + +`author` is a capability this operator's containers gain, so it defaults off — design §13's +prompt-injection bound reads differently with it on, and that should be a decision somebody made. +`dispatch` is about this operator's model budget being spent on work somebody else's agent asked +for, which has been possible since auto-review shipped, so it defaults on; `false` is the +conservative exit, and it changes nothing convergent (a request this daemon declines stays open for +whoever will take it). Both the dispatcher and the claim manager read it, so this daemon never claims +work it would then refuse to run. + +What the daemon owns, and the container therefore cannot supply: the goal/project anchor (taken from +the commissioning request), `commissionedBy` (which names that request), the absence of an assignee +(commissioned requests are open — claims arbitrate), the deterministic rkey, and the resolution of +every `--based-on` locator against this daemon's own index. What the socket refuses: + +- a `review` or `answer` TURN commissioning anything (a verdict never triggers further generation; a + reply never commissions the next reply); +- `review` or `answer` as the type being commissioned; +- a turn whose OWN request an agent authored — the one-hop iteration bound, keyed on the fold's + agent-member set, so a fan-out cannot fan out again; +- an unregistered type, or one whose scope does not match the commissioning request's anchor; +- a blank or over-long brief, more than `max` requests, and any `--based-on` ref this space has no + artifact for. + +The rkey mixes in the sub-request's ordinal, so a retried turn's nth request collides with and adopts +its predecessor's nth instead of commissioning the work twice; the brief is deliberately not compared +on adoption (a retry legitimately rewords), but a different TYPE at the same ordinal is refused. +A turn that commissioned work and then produced no terminal record is still a crashed turn and is +retried — safely, for the same reason. `radiald` logs what each turn commissioned and records it on +the turn's ledger row; `radial request retract` is the human stop. + +**The daemon never authors an `answer` request**, and there are exactly two writers of an +`artifactRequest` under `src/`. `auto-review.ts` builds an open `review`; `turn-socket.ts` builds +what a turn commissioned, refusing `review` and `answer` outright — which is what keeps a reply from +commissioning the next reply (design §10, and §14's rejected alternative C). The properties are +asserted over the source in `test/auto-review.test.mjs`, "the daemon writes no other request": the +allowlist of writer files, and, for the socket, the refusals themselves. ## Private spaces diff --git a/packages/daemon/src/claims.ts b/packages/daemon/src/claims.ts index 5d5dfe5..3be02cd 100644 --- a/packages/daemon/src/claims.ts +++ b/packages/daemon/src/claims.ts @@ -120,7 +120,8 @@ const rowClaim = (row: ClaimRow): ClaimRecord | undefined => * - unassigned, and open; * - of a type that resolves (a registry type of the owning scope, or a review whose subject * resolves) — via the shared `resolveRequestType`, not a second copy of that rule; - * - authored by an active member; + * - authored by an active member, and — where the operator set `run.agentRequests.dispatch: false` + * — not by one of the space's agents, mirroring the dispatch gate; * - producible by a loaded actor of ours that holds an active AGENT grant here; * - not blocked on an unanswered question from any active agent in the convergent fold; * - eligible in the turn ledger (a request we have already given up on is not worth claiming), OR @@ -141,7 +142,7 @@ export function selectClaimable( actors: ActorRegistry, claims: ClaimLedger, turns: TurnLedger, - options: { now?: string; budget: number; capacity?: number }, + options: { now?: string; budget: number; capacity?: number; allowAgentAuthored?: boolean }, ): ClaimCandidate[] { if (options.budget <= 0) return [] const typeContext = requestTypeContext(index) @@ -153,6 +154,7 @@ export function selectClaimable( index.members.filter((member) => member.active && member.kind === 'agent').map((member) => member.did), ) const ourDids = new Set(actors.all.map((actor) => actor.did)) + const allowAgentAuthored = options.allowAgentAuthored ?? true const candidates: ClaimCandidate[] = [] const evaluate = (view: GoalView | ProjectView, expectedScope: 'goal' | 'project'): void => { @@ -161,6 +163,10 @@ export function selectClaimable( const type = request.value.type if (!resolveRequestType(request, typeContext, expectedScope)) continue if (!index.members.some((member) => member.did === request.did && member.active)) continue + // Mirrors `selectDispatchable`'s local-policy gate: an operator who will not RUN + // agent-authored work must not claim it either, or the request looks spoken for and nothing + // happens — the one failure mode of a claim manager a human never sees. + if (!allowAgentAuthored && activeAgentDids.has(request.did)) continue if (view.awaitingInput.includes(request.uri)) continue // A claim held by somebody else, still live in the fold, is theirs — do not contest it. (Our @@ -242,6 +248,10 @@ export interface ClaimManagerDeps { capacity?: () => number /** Called when a claim we hold is lost while its turn is running (`TurnDispatcher.abandon`). */ abandon?: (requestUri: string, reason: string) => void + /** Whether this daemon will run requests an AGENT authored (`run.agentRequests.dispatch`, default + * true). Threaded here as well as into the dispatcher so the two gates cannot drift: claiming work + * we would then refuse to dispatch leaves the request looking taken and nothing happening. */ + allowAgentAuthored?: boolean log?: (message: string) => void } @@ -530,6 +540,7 @@ export class ClaimManager { now: nowIso, budget, capacity, + allowAgentAuthored: this.#deps.allowAgentAuthored ?? true, }) for (const candidate of candidates) { const key = `${candidate.request.uri} ${candidate.actor.did}` diff --git a/packages/daemon/src/cli.ts b/packages/daemon/src/cli.ts index 791781d..cb243f0 100644 --- a/packages/daemon/src/cli.ts +++ b/packages/daemon/src/cli.ts @@ -23,6 +23,7 @@ import { indexDigest, retireDeviceAddress, rotateDeviceKey, + TURN_LIMITS, type MaterializedIndex, } from '@radial/core' import { FileDeviceKeyStore } from '@radial/core/node' @@ -282,6 +283,9 @@ export interface ResolvedRunConfig { forges: ForgeConfig[] /** Claim/lease timing for open requests, fully defaulted (design §11). */ claims: ClaimTiming + /** Agent-authored requests, fully defaulted (design §7, §13): `author` off, `dispatch` on, and + * the per-turn cap at the protocol ceiling. */ + agentRequests: { author: boolean; dispatch: boolean; max: number } /** Jetstream ingestion, when the operator opted in. `backfillIntervalMs` is defaulted here. */ jetstream?: { endpoint: string; backfillIntervalMs: number } } @@ -349,6 +353,13 @@ export function resolveRunConfig( ...(run.forge !== undefined ? { forge: run.forge } : {}), forges: configuredForges(run), claims: resolveClaimTiming(run.claims), + // Default-off authoring is design §13's posture, kept exactly for an operator who set nothing; + // default-on dispatch is what every deployment has done since auto-review. + agentRequests: { + author: run.agentRequests?.author ?? false, + dispatch: run.agentRequests?.dispatch ?? true, + max: Math.min(run.agentRequests?.max ?? TURN_LIMITS.maxTurnRequests, TURN_LIMITS.maxTurnRequests), + }, ...(run.jetstream !== undefined ? { jetstream: { @@ -1209,6 +1220,7 @@ async function runCommand(args: string[]): Promise { ...(githubToken ? { githubToken } : {}), }), implementationEnabled: !!githubToken, + agentRequests: run.agentRequests, runTurn: boundRunTurn, ...(codexAuth ? { serializedHarnesses: ['codex'] } : {}), // Unassigned requests dispatch only for a claim this daemon wrote and has watched win. @@ -1299,6 +1311,7 @@ async function runCommand(args: string[]): Promise { now: () => Date.now(), capacity: () => run.concurrency - dispatcher.inFlight, abandon: (requestUri, reason) => dispatcher.abandon(requestUri, reason), + allowAgentAuthored: run.agentRequests.dispatch, log: (message) => console.log(message), }) diff --git a/packages/daemon/src/config.ts b/packages/daemon/src/config.ts index 446647e..d02c686 100644 --- a/packages/daemon/src/config.ts +++ b/packages/daemon/src/config.ts @@ -1,6 +1,6 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' -import { MAX_CLAIM_LEASE_MS, type AgentModel } from '@radial/core' +import { MAX_CLAIM_LEASE_MS, TURN_LIMITS, type AgentModel } from '@radial/core' import { selectHarness } from './harness.js' import type { AgentInitInput } from './init.js' @@ -134,6 +134,11 @@ export interface DaemonRunConfig { * default (`resolveRunConfig`); the whole block is optional and only matters to an operator whose * space uses open requests. See `ClaimsConfig` for what each knob buys. */ claims?: ClaimsConfig + /** + * Agent-authored artifact requests (design §7, §13) — a turn commissioning further targeted work. + * The whole block is optional and both knobs have runtime defaults; see `AgentRequestsConfig`. + */ + agentRequests?: AgentRequestsConfig /** Jetstream ingestion (design §5). Opt-in and a LATENCY optimisation only: polling stays * authoritative and remains the backfill, so an unreachable endpoint costs latency and nothing * else. Absent means "poll only", which is what every deployment before Phase 7 did. */ @@ -144,6 +149,26 @@ export interface CodexAuthConfig { mode: 'chatgpt-session' } +/** + * Whether this daemon's turns may commission work, and whether it will run work agents commissioned. + * + * The two are separate because they are separate risks. `author` is about THIS operator's containers + * gaining a new capability — design §13's prompt-injection bound used to read "it cannot commission + * new work", so enabling it is a deliberate relaxation and the default must stay off. `dispatch` is + * about this operator's model budget being spent on work SOMEBODY ELSE's agent asked for, which has + * been possible since auto-review and so defaults to on; setting it false is the conservative + * operator's exit, and it changes nothing convergent (dispatch policy is already per-operator). + */ +export interface AgentRequestsConfig { + /** Let a turn write `artifactRequest` records through the turn socket. Runtime default FALSE. */ + author?: boolean + /** Run requests an agent authored, this daemon's own included. Runtime default TRUE. */ + dispatch?: boolean + /** How many requests one turn may commission. Runtime default `TURN_LIMITS.maxTurnRequests` (10); + * it may be lowered but never raised above it — the ceiling is the protocol's, not the config's. */ + max?: number +} + /** One private space this daemon serves (design §18). */ export interface PrivateSpaceConfig { /** The space's `at://` URI. A name here, not a location: no PDS holds the record. */ @@ -457,6 +482,32 @@ function privateSpacesValue(value: unknown, where: string): PrivateSpaceConfig[] }) } +/** + * Validates `run.agentRequests`. `max` is bounded ABOVE by `TURN_LIMITS.maxTurnRequests` at parse + * time rather than clamped silently: an operator who wrote 100 asked for a fan-out this daemon will + * not perform, and a config that quietly means 10 is how a spend surprise gets built. + */ +function agentRequestsValue(value: unknown, where: string): AgentRequestsConfig | undefined { + if (value === undefined) return undefined + if (!object(value)) throw new TypeError(`${where} must be an object`) + const author = bool(value.author, `${where}.author`) + const dispatch = bool(value.dispatch, `${where}.dispatch`) + const max = num(value.max, `${where}.max`) + if (max !== undefined && (!Number.isInteger(max) || max < 1)) { + throw new TypeError(`${where}.max must be an integer of at least 1`) + } + if (max !== undefined && max > TURN_LIMITS.maxTurnRequests) { + throw new TypeError( + `${where}.max must be at most ${TURN_LIMITS.maxTurnRequests} — the per-turn ceiling the turn socket enforces`, + ) + } + return { + ...(author !== undefined ? { author } : {}), + ...(dispatch !== undefined ? { dispatch } : {}), + ...(max !== undefined ? { max } : {}), + } +} + function jetstreamValue(value: unknown, where: string): JetstreamConfig | undefined { if (value === undefined) return undefined if (!object(value)) throw new TypeError(`${where} must be an object`) @@ -524,6 +575,7 @@ export function parseRunConfig(value: unknown): DaemonRunConfig { const forges = forgesValue(value.forges, 'run.forges') const claims = claimsValue(value.claims, 'run.claims') const jetstream = jetstreamValue(value.jetstream, 'run.jetstream') + const agentRequests = agentRequestsValue(value.agentRequests, 'run.agentRequests') const privateSpaces = privateSpacesValue(value.privateSpaces, 'run.privateSpaces') if (forge && forges) { throw new TypeError('run.forge and run.forges are two spellings of the same setting; keep one') @@ -565,6 +617,7 @@ export function parseRunConfig(value: unknown): DaemonRunConfig { ...(forges !== undefined ? { forges } : {}), ...(claims !== undefined ? { claims } : {}), ...(jetstream !== undefined ? { jetstream } : {}), + ...(agentRequests !== undefined ? { agentRequests } : {}), } } diff --git a/packages/daemon/src/dispatch.ts b/packages/daemon/src/dispatch.ts index 3432356..b709b84 100644 --- a/packages/daemon/src/dispatch.ts +++ b/packages/daemon/src/dispatch.ts @@ -2,6 +2,7 @@ import { join } from 'node:path' import { activeGoals, activeProjects, + agentMemberDids, buildTurnBundle, COLLECTIONS, compareCodePoints, @@ -21,7 +22,7 @@ import { remoteBranchExists } from './bundle-writer.js' import { ForgeRegistry, type ForgeAdapter } from './forge.js' import { urlOnlyForgeAdapters } from './forge-github.js' import type { TurnLedger } from './ledger.js' -import { implBranchName, planArtifactRkey, type TurnRequestAnchor } from './turn-socket.js' +import { implBranchName, planArtifactRkey, type CommissionContext, type TurnRequestAnchor } from './turn-socket.js' import { ANSWER_TYPE, ANSWER_TYPE_NAME, IMPLEMENTATION_TYPE, REVIEW_TYPE, REVIEW_TYPE_NAME, turnContainerLabel, type TurnInput, type TurnResult } from './turn.js' /** A resolved v2 predecessor: the exact prior artifact, plus the branch/PR links an implementation @@ -331,8 +332,18 @@ export function selectDispatchable( * Assigned requests never consult it. */ heldClaims: ReadonlySet = new Set(), + /** + * Per-operator dispatch policy for requests an AGENT authored (design §13). Default true, which is + * what every operator has had since auto-review: an agent-authored request is dispatched like any + * other. `false` is the conservative knob for an operator who does not want to spend their model + * budget executing another operator's agents' fan-out. It changes nothing convergent — dispatch + * policy is already per-operator, and a request this daemon declines stays open for whoever will. + */ + options: { allowAgentAuthored?: boolean } = {}, ): Dispatchable[] { const typeContext = requestTypeContext(index) + const allowAgentAuthored = options.allowAgentAuthored ?? true + const agentDids = allowAgentAuthored ? new Set() : agentMemberDids(index) // Awaiting-input is checked against the SPACE-GLOBAL trusted messages: a project-scoped capture // turn's question is anchored to the source goal (a different view than the request's), so a // per-view message scan would miss it and the request would be redispatched over the open question. @@ -363,6 +374,15 @@ export function selectDispatchable( onReject?.(request.uri, `author ${request.did} is not an active member of the space`) return undefined } + // Local policy, not a protocol rule: an operator may decline to EXECUTE agent-authored work + // (auto-review's requests included) without that changing what anyone's fold says about it. + if (!allowAgentAuthored && agentDids.has(request.did)) { + onReject?.( + request.uri, + `authored by agent ${request.did} and run.agentRequests.dispatch is false on this daemon`, + ) + return undefined + } // Assigned → that agent handles it, with no claim and no extra latency (the whole // single-operator path). Unassigned → the fold's claim tie-break decides, and this daemon @@ -584,6 +604,18 @@ export interface DispatcherDeps { * no-forge fallback below safe: without it, a predecessor whose branch was deleted after its PR * merged sends every retry of a v2 into the same failing clone. */ branchExists?: (input: { gitUrl: string; branch: string }) => Promise + /** + * Agent-authored requests (design §7, §13), both halves of the knob: + * + * - `author` (default FALSE) is what lets a turn commission further targeted work through the + * turn socket at all. Default-off keeps §13's injection posture exactly as it was for every + * operator who did not ask for this. + * - `dispatch` (default TRUE) is whether this daemon EXECUTES requests an agent authored — + * including another operator's agents', and including auto-review's, which is why it defaults + * to what every deployment already does. + * - `max` caps how many one turn may commission; unset is `TURN_LIMITS.maxTurnRequests`. + */ + agentRequests?: { author?: boolean; dispatch?: boolean; max?: number } /** Disable implementation launches when daemon GitHub possession is unavailable. Superseded, per * project, by the matching adapter's own `implementationBlockedReason()`: one missing GitHub * token used to stop implementations on projects that never needed one. */ @@ -657,7 +689,13 @@ export class TurnDispatcher { // Rejections are ordinary on every sync tick (requests for another operator, exhausted // capacity, unresolved claims, and so on). Keep them out of the daemon log; actionable turn // failures are still logged by the launch and settlement paths below. - const dispatchable = selectDispatchable(index, actors, this.#deps.ledger, now, undefined, heldClaims) + const dispatchable = selectDispatchable(index, actors, this.#deps.ledger, now, undefined, heldClaims, { + allowAgentAuthored: this.#deps.agentRequests?.dispatch ?? true, + }) + // What a turn of this index may commission, computed ONCE per pump and only where the operator + // opted in: the registry and the artifact set are the same for every turn launched from one + // snapshot, and the socket gets closures over them rather than the index itself. + const commissionFor = this.#commissionContext(index) // The claim view is a cache of the fold, and a cache can be stale in the dangerous direction: // `heldClaims` still naming a request whose claim another operator has since won. So the winner // is re-read from THIS index, immediately before launching, for every unassigned request. @@ -738,7 +776,13 @@ export class TurnDispatcher { if (contested) this.#activeBranches.set(contested, uri) if (serializedHarness) this.#activeSerializedHarnesses.add(serializedHarness) - const promise = this.#launch(item, { anchor, runDir, label }) + const commission = commissionFor(item.request) + const promise = this.#launch(item, { + anchor, + runDir, + label, + ...(commission ? { commission } : {}), + }) .finally(() => { this.#inFlight.delete(uri) // A relaunch (a lease reclaimed after the winner crashed) must be abandonable again. @@ -750,6 +794,41 @@ export class TurnDispatcher { } } + /** + * The commissioning context every turn launched from THIS index snapshot gets, or a function + * returning undefined when the operator has not opted in (`run.agentRequests.author`) — which is + * what makes "off" mean the socket refuses rather than the socket allowing something narrower. + * + * The registry and the artifact index are built once per pump: they are the same for every turn, + * and building them per launch would re-walk every view for each container. What varies per turn is + * the one-hop bound, which reads the COMMISSIONING request's author against the fold's own + * agent-member set (`agentMemberDids` in core — the same set the fold's awaiting-input scan uses). + */ + #commissionContext(index: MaterializedIndex): (request: IndexedRecord) => CommissionContext | undefined { + if (!this.#deps.agentRequests?.author) return () => undefined + const { registry, artifactByRef } = requestTypeContext(index) + const agentDids = agentMemberDids(index) + // A bare `at://…` locator — which is what a bundle shows a turn — resolves to the version the + // index holds, of which there is exactly one per URI: the fold adopts no in-place artifact edit, + // it annotates it. `artifactByRef` is keyed by uri#cid and answers a pinned locator. + const byUri = new Map() + for (const artifact of artifactByRef.values()) { + byUri.set(artifact.uri, { uri: artifact.uri, cid: artifact.cid }) + } + const max = this.#deps.agentRequests.max + return (request) => ({ + commissionerIsAgent: agentDids.has(request.did), + resolveType: (type: string) => registry.get(type), + resolveArtifact: (locator: string) => { + const hash = locator.indexOf('#') + if (hash === -1) return byUri.get(locator) + const pinned = { uri: locator.slice(0, hash), cid: locator.slice(hash + 1) } + return artifactByRef.has(refKey(pinned)) ? pinned : undefined + }, + ...(max !== undefined ? { max } : {}), + }) + } + /** The injected remote probe, or a plain `git ls-remote` under this dispatcher's scheme allowlist. * The default carries no credential, so a private repository wants the injected one (cli.ts binds * the daemon's token into it); an unauthenticated probe there throws, and the caller's catch @@ -772,7 +851,7 @@ export class TurnDispatcher { /** Prepare and run one turn, then settle the ledger. */ async #launch( item: Dispatchable, - ctx: { anchor: TurnRequestAnchor; runDir: string; label: string }, + ctx: { anchor: TurnRequestAnchor; runDir: string; label: string; commission?: CommissionContext }, ): Promise { const { anchor, runDir, label } = ctx const uri = anchor.uri @@ -878,6 +957,7 @@ export class TurnDispatcher { // `branch` remains implementation-specific. ...(item.predecessor?.artifact ? { prev: item.predecessor.artifact.ref } : {}), ...(isImpl && branch !== undefined ? { branch } : {}), + ...(ctx.commission ? { commission: ctx.commission } : {}), ...(item.answer ? { answer: { @@ -895,6 +975,14 @@ export class TurnDispatcher { // exists on the forge whatever became of the turn, and a retry that finds it here does not // have to re-list the agent's repo to know so. if (result.pull) this.#deps.ledger.markPull(uri, result.pull.url) + // Recorded before the outcome, and for the same reason a pull is: the requests exist whatever + // became of the turn, and a crashed turn that commissioned three plans commissioned them. + if (result.requests?.length) { + this.#deps.ledger.markRequests(uri, result.requests.map((request) => request.uri)) + this.#deps.log?.( + `turn ${uri} commissioned ${result.requests.length} request(s): ${result.requests.map((request) => request.uri).join(', ')}`, + ) + } if (result.outcome === 'fulfilled') { this.#deps.ledger.markFulfilled(uri, result.acceptedRef as StrongRef) } else if (result.outcome === 'awaiting_input') { diff --git a/packages/daemon/src/harness.ts b/packages/daemon/src/harness.ts index eb071d1..68f4a15 100644 --- a/packages/daemon/src/harness.ts +++ b/packages/daemon/src/harness.ts @@ -27,6 +27,10 @@ export interface HarnessInvocationInput { * prompt, and only in the two places the forges genuinely differ: how the branch is pushed, and * who opens the pull request. Unset (or `github`) keeps the GitHub prompt exactly as it was. */ forge?: 'github' | 'tangled' + /** Present when THIS turn may commission further targeted work (design §7, §13) — the operator + * opted in and the socket will accept it. Absent is the default and says nothing at all: a prompt + * that offered a command the socket refuses would spend a turn discovering that. */ + commission?: { max: number } } /** What one line of container stdout means to the harness that produced it: the operator-facing @@ -89,6 +93,8 @@ export function buildPrompt(input: { review?: boolean answer?: boolean forge?: 'github' | 'tangled' + /** See `HarnessInvocationInput.commission`: present only when the socket will accept it. */ + commission?: { max: number } }): string { const context = [ `Read your brief at ${input.bundleDir}/brief.md — the artifact-type brief plus this request's extra instructions.`, @@ -101,6 +107,16 @@ export function buildPrompt(input: { // chose on purpose is the only kind that stays predictable. const titleRule = 'The `--title` is a short phrase naming what you delivered — how a row in a list should read, at most 200 characters. Not a sentence, not the opening line of your body, and not a restatement of the request.' + // Said only when the socket will actually accept it, and said with its bound: an agent told it can + // commission work but not how much — or not told that what it commissions cannot commission more — + // divides the work badly. Never offered to a review or an answer turn; neither may commission. + const commissioning = input.commission + ? [ + '', + `This turn may also COMMISSION further targeted work, up to ${input.commission.max} request(s): run \`radial request create --type --brief "" [--based-on ]...\` for each. Use it where the work genuinely divides — "this needs three separate plans", not "somebody should look at this too". Each becomes an OPEN request any capable agent may pick up, anchored to this turn's own goal or project; you do not choose who runs it, and you cannot commission a review or an answer.`, + 'It is one hop: the turns your requests dispatch cannot commission anything further, so whatever you leave for them to divide will not be divided. It is also not a substitute for your own terminal record — you still owe exactly one, and a turn that commissions work and delivers nothing has failed.', + ] + : [] if (input.answer) { return [ ...context, @@ -153,6 +169,7 @@ export function buildPrompt(input: { ` ${titleRule}`, '', 'If you cannot complete the brief and need input first: run `radial message post --body ""` instead, do NOT commit, and stop.', + ...commissioning, '', 'Ordinary web fetches are allowed for reconnaissance. Do not merge. `radial` is on PATH and is authenticated only for protocol submission.', ].join('\n') @@ -175,6 +192,7 @@ export function buildPrompt(input: { ` ${titleRule}`, '', 'If you cannot complete the brief and need input first: run `radial message post --body ""` instead, do NOT commit, and stop.', + ...commissioning, '', 'Forge reconnaissance (`gh pr view`, `gh api`, and ordinary web fetches) is allowed. Do not merge. `radial` is on PATH and is authenticated only for protocol submission.', ].join('\n') @@ -187,6 +205,7 @@ export function buildPrompt(input: { '- If you can complete the brief: write your result to a file, then deliver it by running `radial artifact submit --title "" --body-file `.', ` ${titleRule}`, '- If you cannot complete the brief and need input first: run `radial message post --body ""` instead, and stop.', + ...commissioning, '', 'Do not print your result to stdout — it is ignored. `radial` is on PATH and is already authenticated for this turn (unix-socket mode); do not look for or ask for credentials.', ].join('\n') diff --git a/packages/daemon/src/ledger.ts b/packages/daemon/src/ledger.ts index 51a7999..ddff4cb 100644 --- a/packages/daemon/src/ledger.ts +++ b/packages/daemon/src/ledger.ts @@ -19,6 +19,11 @@ export interface TurnRow { * (tangled). Belt and braces for the crash window between writing the pull record and writing the * artifact: a retry finds the record here instead of re-listing the agent's repo for it. */ pullUri?: string + /** The requests this turn COMMISSIONED (design §7, §13), by URI, in the order it wrote them. + * Diagnostic only — nothing dispatches from it, and the requests themselves are records in the + * agent's repo like any other. It is here for the operator's question "what did that turn set + * going", which is otherwise answerable only by reading the repo. */ + requests?: string[] acceptedRef?: StrongRef updatedAt: string } @@ -39,11 +44,25 @@ interface Row { checkout_path: string | null branch: string | null pull_uri: string | null + requests_json: string | null accepted_ref_uri: string | null accepted_ref_cid: string | null updated_at: string | null } +/** The stored request list, or nothing at all — a torn/unreadable value is dropped rather than + * failing a read of the row it annotates: it is diagnostic, and the turn state is not. */ +function parseRequests(value: string | null): { requests: string[] } | undefined { + if (!value) return undefined + try { + const parsed: unknown = JSON.parse(value) + if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== 'string')) return undefined + return parsed.length ? { requests: parsed as string[] } : undefined + } catch { + return undefined + } +} + function toRow(row: Row): TurnRow { return { requestUri: row.request_uri, @@ -55,6 +74,7 @@ function toRow(row: Row): TurnRow { ...(row.checkout_path ? { checkoutPath: row.checkout_path } : {}), ...(row.branch ? { branch: row.branch } : {}), ...(row.pull_uri ? { pullUri: row.pull_uri } : {}), + ...(parseRequests(row.requests_json) ?? {}), ...(row.accepted_ref_uri && row.accepted_ref_cid ? { acceptedRef: { uri: row.accepted_ref_uri, cid: row.accepted_ref_cid } } : {}), @@ -82,6 +102,7 @@ export class TurnLedger { checkout_path TEXT, branch TEXT, pull_uri TEXT, + requests_json TEXT, accepted_ref_uri TEXT, accepted_ref_cid TEXT, submission_json TEXT, @@ -98,6 +119,9 @@ export class TurnLedger { if (!columns.some((column) => column.name === 'pull_uri')) { this.#database.exec('ALTER TABLE turns ADD COLUMN pull_uri TEXT') } + if (!columns.some((column) => column.name === 'requests_json')) { + this.#database.exec('ALTER TABLE turns ADD COLUMN requests_json TEXT') + } this.#retryBound = options.retryBound ?? 3 this.#cooldownMs = options.cooldownMs ?? 300_000 @@ -180,6 +204,15 @@ export class TurnLedger { .run(pullUri, uri) } + /** Records what this turn commissioned. Like `markBranch`/`markPull`: after the fact, never a + * state change, so it is safe on a row that has already settled — a turn that commissioned work + * and then crashed still commissioned it. */ + markRequests(uri: string, requests: string[]): void { + this.#database + .prepare('UPDATE turns SET requests_json = ? WHERE request_uri = ?') + .run(JSON.stringify(requests), uri) + } + markFulfilled(uri: string, ref: StrongRef): void { const old = this.get(uri) const now = this.#now() diff --git a/packages/daemon/src/turn-socket.ts b/packages/daemon/src/turn-socket.ts index f518962..930ef86 100644 --- a/packages/daemon/src/turn-socket.ts +++ b/packages/daemon/src/turn-socket.ts @@ -6,10 +6,14 @@ import { XrpcError } from '@radial/atproto' import { ForgeRegistry, type ForgeAdapter } from './forge.js' import { urlOnlyForgeAdapters } from './forge-github.js' import { + ANSWER_TYPE_NAME, COLLECTIONS, + REVIEW_TYPE_NAME, TURN_LIMITS, normalizeArtifactTitle, type ArtifactRecord, + type ArtifactRequestRecord, + type ArtifactTypeRecord, type Collection, type MessageRecord, type RadialRecord, @@ -71,6 +75,31 @@ export type TurnRequestAnchor = | { uri: string; cid: string; goal: StrongRef; project?: undefined } | { uri: string; cid: string; project: StrongRef; goal?: undefined } +/** + * What a turn may COMMISSION, resolved daemon-side (design §7, §13). Its presence is the operator's + * opt-in: `run.agentRequests.author` is default-off, and without it the dispatcher threads nothing + * here and `requestArtifact` is refused outright. Every field is an answer the daemon computed from + * its own index at dispatch time — the socket holds booleans and closures, never the index itself, + * for the same reason it holds a turn mode rather than the request record. + */ +export interface CommissionContext { + /** + * Whether the COMMISSIONING request's own author is one of the space's agents, per the fold's + * agent-member set (`agentMemberDids`). This is the iteration bound, and it is structural: a + * human's request may fan out, and the requests that fan-out writes may not fan out again. Depth + * one, with no counter to maintain and no fold rule to disagree about. + */ + commissionerIsAgent: boolean + /** The registry type a name binds in this space, or undefined for a name nothing registers. */ + resolveType: (type: string) => ArtifactTypeRecord | undefined + /** A `--based-on` locator (`at://…` or `at://…#cid`) resolved against the daemon's own index: the + * exact artifact version to copy into the record, or undefined for one this space has no artifact + * for. Refs off the wire are untrusted — this is what makes them a record's contents. */ + resolveArtifact: (locator: string) => StrongRef | undefined + /** Per-turn cap; defaults to `TURN_LIMITS.maxTurnRequests`. */ + max?: number +} + export interface TurnRequestContext { token: string /** The request being fulfilled, with its goal- or project-scope anchor. */ @@ -112,6 +141,9 @@ export interface TurnRequestContext { * daemon threads the resolved anchor here: the capture source goal, else the lowest-uri resolved * basedOn artifact. Absent → a direct project turn without basedOn cannot ask a question (v1). */ questionAnchor?: { goal: StrongRef } | { artifact: StrongRef } + /** What this turn may commission, when the operator enabled it (see `CommissionContext`). Absent + * means `requestArtifact` is refused — the default, which preserves design §13's posture exactly. */ + commission?: CommissionContext } export interface TurnObservation { @@ -120,6 +152,10 @@ export interface TurnObservation { /** An answer turn's terminal record: the thread reply it posted. */ answer?: StrongRef questions: StrongRef[] + /** Sub-requests this turn commissioned, in the order it wrote them. Non-terminal: a turn may + * commission work and still submit its own artifact, and commissioning alone is not a fulfilment + * (see `classifyTurnObservation`). */ + requests: StrongRef[] /** The pull request the daemon opened on the turn's behalf, where it did (tangled): the value it * stamped into `links.pr`, and the record it wrote. */ pull?: { url: string; record?: StrongRef } @@ -130,6 +166,10 @@ export interface TurnSocketLimits { frameTimeoutMs: number artifactBodyChars: number messageBodyChars: number + /** The lexicon's `artifactRequest.brief` ceiling. Optional so a test (or an operator's narrower + * limits object) that predates commissioning still satisfies the type; unset falls back to + * `TURN_LIMITS.requestBriefChars`, never to "unbounded". */ + requestBriefChars?: number } /** @@ -203,6 +243,22 @@ export function answerRkey(requestUri: string, requestCid: string): string { return artifactRkey(requestUri, requestCid, 'answer-') } +/** + * `reqn-` prefixed deterministic rkey for the Nth request a turn commissions: 24 base32 chars of + * sha256(requestUri#requestCid#n). The ORDINAL is what makes it usable at all — a turn writes + * several of these, so unlike every other terminal rkey the request alone does not identify the + * record. A retried turn's nth request therefore collides with its predecessor's nth and adopts it + * (see #requestArtifact) instead of commissioning the same work twice. + * + * Residual, deliberate: a retry that commissions its requests in a DIFFERENT order strands or + * duplicates within the cap. Bounded, attributable, and retractable — the same class of corner + * auto-review's removed-writer case accepts. + */ +export function turnRequestRkey(requestUri: string, requestCid: string, ordinal: number): string { + const digest = createHash('sha256').update(`${requestUri}#${requestCid}#${ordinal}`).digest() + return `reqn-${base32Encode(digest).slice(0, 24)}` +} + /** Deterministic push branch for a fresh implementation turn: `radial/impl-<12 hex of * sha256(requestUri#requestCid)>`. Stable across retries so a re-push targets the same branch. (A * v2 turn reusing an open predecessor PR pushes to the predecessor's branch instead.) */ @@ -228,6 +284,7 @@ type ParsedEnvelope = | { method: 'askQuestion'; token: string; body: string } | { method: 'submitAnswer'; token: string; body: string } | { method: 'submitReview'; token: string; verdict: 'approve' | 'request_changes'; findings: ReviewFinding[] } + | { method: 'requestArtifact'; token: string; type: string; brief: string; basedOn: string[] } const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -267,11 +324,35 @@ function parseReviewEnvelope(raw: Record, token: string): Parse return { method: 'submitReview', token, verdict, findings: parsed } } +/** + * Shape-only validation of a requestArtifact envelope at the untrusted edge. `type` is bounded by the + * lexicon's own 100 characters; `brief` is a string here and length-checked in `#dispatch` beside the + * other bodies; `basedOn` is a bounded array of strings, each within the atproto URI length the + * daemon's own index keys are. Whether any of it MEANS anything — a registered type, a resolvable + * ref, an operator who opted in — is `#requestArtifact`'s decision, so a turn gets an error saying + * what to do rather than "invalid envelope". + */ +function parseRequestEnvelope(raw: Record, token: string): ParsedEnvelope | undefined { + const { type, brief, basedOn } = raw + if (typeof type !== 'string' || type.length === 0 || type.length > 100) return undefined + if (typeof brief !== 'string') return undefined + let refs: string[] = [] + if (basedOn !== undefined) { + if (!Array.isArray(basedOn) || basedOn.length > TURN_LIMITS.turnRequestBasedOnMax) return undefined + if (basedOn.some((entry) => typeof entry !== 'string' || entry.length === 0 || entry.length > 2048)) { + return undefined + } + refs = basedOn as string[] + } + return { method: 'requestArtifact', token, type, brief, basedOn: refs } +} + function parseEnvelope(raw: unknown): ParsedEnvelope | undefined { if (!isObject(raw)) return undefined const { method, token } = raw if (typeof token !== 'string') return undefined if (method === 'submitReview') return parseReviewEnvelope(raw, token) + if (method === 'requestArtifact') return parseRequestEnvelope(raw, token) if (method !== 'submitArtifact' && method !== 'askQuestion' && method !== 'submitAnswer') return undefined const { body, criteria, branch, commit, pr, title } = raw if (typeof body !== 'string') return undefined @@ -314,13 +395,20 @@ export class TurnSocketServer { readonly #listen: TurnListen readonly #context: TurnRequestContext readonly #limits: TurnSocketLimits - readonly #observation: TurnObservation = { questions: [] } + readonly #observation: TurnObservation = { questions: [], requests: [] } #server: Server | undefined #boundPort: number | undefined // Synchronous in-flight reservation: set the instant #submitArtifact is entered (before any // `await`), so two concurrent connections racing the same server can never both pass the // "already submitted" check before either write lands. See #submitArtifact below. #submitReserved = false + // Ordinals handed out to commissioned sub-requests, incremented synchronously the moment one is + // accepted (before any `await`), so two concurrent connections can never be given the same ordinal + // — which would be the same deterministic rkey, and one would adopt the other's record. It counts + // ATTEMPTS rather than successes: a failed write keeps its ordinal, so it is charged against the + // cap (bounded spend under a turn that keeps failing) and a retry of the whole turn lines its + // successful writes up with the ordinals they landed on before. + #requestsReserved = 0 constructor( listen: TurnListen | string, @@ -453,6 +541,13 @@ export class TurnSocketServer { ) { return { ok: false, error: `body exceeds ${limits.messageBodyChars} characters` } } + if (envelope.method === 'requestArtifact') { + const briefChars = limits.requestBriefChars ?? TURN_LIMITS.requestBriefChars + if (envelope.brief.length > briefChars) { + return { ok: false, error: `brief exceeds ${briefChars} characters` } + } + return this.#requestArtifact(envelope) + } if (envelope.method === 'submitReview') return this.#submitReview(envelope) if (envelope.method === 'submitAnswer') return this.#submitAnswer(envelope) return envelope.method === 'submitArtifact' ? this.#submitArtifact(envelope) : this.#askQuestion(envelope) @@ -677,6 +772,144 @@ export class TurnSocketServer { return { ok: true, ref } } + /** + * A turn commissioning further targeted work: an OPEN `artifactRequest` of its own (design §7, §13). + * + * Non-terminal, and deliberately the narrowest write path in this file. The daemon owns everything + * that anchors the record — the goal/project comes from THIS turn's request, `commissionedBy` names + * it, there is no `assignee` (claims arbitrate, the auto-review shape) — and the container supplies + * only what the daemon cannot know: which registered type of work, the brief for it, and refs it + * would like the sub-turn briefed from, each of which is resolved against the daemon's own index + * rather than trusted off the wire. + * + * Four refusals carry the design, in this order and for these reasons: + * + * 1. `review` and `answer` TURNS commission nothing, whatever the operator configured. A verdict + * never triggers further generation (design §7), and a reply that could commission the next + * reply is design §14's rejected alternative C. + * 2. No `commission` context → the operator has not opted in (`run.agentRequests.author`, default + * off), which is what keeps design §13's posture intact for every daemon that did not ask. + * 3. An agent-authored commissioning request → the one-hop iteration bound (see + * `CommissionContext.commissionerIsAgent`). + * 4. `review`/`answer` as the requested TYPE — those two are the turn layer's own, never + * registry data, and a turn must not be able to order a verdict on its own work or a reply in + * its own thread. + * + * Then the ordinary edge validation: a registered type of this request's own scope, a non-blank + * brief, the per-turn cap, and every `--based-on` ref resolving to an artifact this space holds. + */ + async #requestArtifact(envelope: Extract): Promise { + const { request, client, mode, commission } = this.#context + if (mode.kind === 'review') { + return { ok: false, error: 'this is a review turn; a verdict never commissions further work' } + } + if (mode.kind === 'answer') { + return { ok: false, error: 'this is an answer turn; a reply never commissions further work' } + } + if (!commission) { + return { + ok: false, + error: + 'this daemon does not author agent-requested work; an operator enables it with run.agentRequests.author', + } + } + if (commission.commissionerIsAgent) { + return { + ok: false, + error: + 'this turn is running an agent-authored request, and commissioned work is one hop deep; ask a human to request the next round', + } + } + const type = envelope.type.trim() + if (type === REVIEW_TYPE_NAME || type === ANSWER_TYPE_NAME) { + return { + ok: false, + error: `'${type}' requests are not commissionable by a turn; a ${type} is asked for by a human (or, for a review, by auto-review)`, + } + } + const registered = commission.resolveType(type) + if (!registered) { + return { ok: false, error: `'${type}' is not a registered artifact type in this space` } + } + // The sub-request hangs off THIS request's anchor, so the type has to be of that anchor's scope: + // a project-scoped type under a goal is a record the materializer would file nowhere. + const scope = request.goal ? 'goal' : 'project' + if (registered.scope !== scope) { + return { + ok: false, + error: `'${type}' is ${registered.scope}-scoped and this turn's request is anchored to a ${scope}`, + } + } + const brief = envelope.brief.trim() + if (brief === '') { + return { ok: false, error: 'request create requires a non-blank --brief: what the commissioned turn must do' } + } + const max = commission.max ?? TURN_LIMITS.maxTurnRequests + if (this.#requestsReserved >= max) { + return { ok: false, error: `this turn may commission at most ${max} requests` } + } + const basedOn: StrongRef[] = [] + for (const locator of envelope.basedOn) { + const resolved = commission.resolveArtifact(locator) + if (!resolved) { + return { ok: false, error: `--based-on ${locator} does not name an artifact of this space` } + } + basedOn.push(resolved) + } + // Reserve the ordinal synchronously, before the first `await`: it is the rkey. + const ordinal = this.#requestsReserved + this.#requestsReserved += 1 + + const anchor = request.goal ? { goal: request.goal } : { project: request.project as StrongRef } + const commissionedBy: StrongRef = { uri: request.uri, cid: request.cid } + const record: ArtifactRequestRecord = { + $type: COLLECTIONS.artifactRequest, + ...anchor, + type, + basedOn, + brief, + commissionedBy, + createdAt: this.#now(), + } + const rkey = turnRequestRkey(request.uri, request.cid, ordinal) + let ref: StrongRef + try { + ref = await client.create(COLLECTIONS.artifactRequest, record, { rkey }) + } catch (error) { + if (error instanceof XrpcError && error.status === 400 && error.error === 'RecordAlreadyExists') { + const existing = await client.getOwnRecord(COLLECTIONS.artifactRequest, rkey) + if (!existing) throw error + const current = existing.value as ArtifactRequestRecord + const currentAnchor = request.goal ? current.goal : current.project + const strayAnchor = request.goal ? current.project : current.goal + const expectedAnchor = request.goal ?? (request.project as StrongRef) + // Adopt iff the record at this rkey is one THIS turn would have written: same type, same + // anchor, same commissioning request, and still open. The BRIEF is deliberately not + // compared — a retried turn legitimately writes different prose, exactly as a retried + // review writes different findings — but a different TYPE at the same ordinal means the + // retry commissioned something else, and adopting there would report work that was never + // requested as requested. + if ( + current.$type !== COLLECTIONS.artifactRequest || + current.type !== type || + currentAnchor?.uri !== expectedAnchor.uri || + currentAnchor.cid !== expectedAnchor.cid || + strayAnchor !== undefined || + current.commissionedBy?.uri !== commissionedBy.uri || + current.commissionedBy.cid !== commissionedBy.cid || + current.assignee !== undefined + ) { + throw new Error(`refusing to adopt commissioned request at ${rkey}: it is not the one this turn wrote`) + } + ref = { uri: existing.uri, cid: existing.cid } + } else { + throw error + } + } + this.#observation.requests.push(ref) + return { ok: true, ref } + } + /** * An answer turn's terminal record: a reply in the goal's thread. * diff --git a/packages/daemon/src/turn.ts b/packages/daemon/src/turn.ts index 0f21484..475520b 100644 --- a/packages/daemon/src/turn.ts +++ b/packages/daemon/src/turn.ts @@ -4,6 +4,7 @@ import { ANSWER_TYPE_NAME, COLLECTIONS, compareCodePoints, + TURN_LIMITS, type ArtifactRequestRecord, type ArtifactTypeRecord, type StrongRef, @@ -15,7 +16,7 @@ import type { CodexAuthLease, CodexAuthStore } from './codex-auth.js' import { RADIAL_HOME_TMPFS, radialHomeTmpfs, type ContainerOutputStream, type ContainerRunner, type ContainerSpec } from './container.js' import type { ForgeRegistry, TurnForgeGrant } from './forge.js' import { composeBrief, type Harness, type HarnessOutput } from './harness.js' -import { TurnSocketServer, implArtifactRkey, planArtifactRkey, type TurnMode, type TurnObservation, type TurnRequestAnchor, type TurnRequestContext } from './turn-socket.js' +import { TurnSocketServer, implArtifactRkey, planArtifactRkey, type CommissionContext, type TurnMode, type TurnObservation, type TurnRequestAnchor, type TurnRequestContext } from './turn-socket.js' /** The exact built-in artifact-type name that gets the implementation code path (rw /work and * git identity). Every other type dispatches as a plan-style turn. */ @@ -92,6 +93,10 @@ export interface TurnResult { label: string /** The pull request the daemon opened for this turn, where it opened one (tangled). */ pull?: { url: string; record?: StrongRef } + /** Sub-requests this turn commissioned (design §7, §13), in the order it wrote them. Reported + * whatever the outcome: the records exist, and an operator reading a crashed turn's line needs to + * know what it commissioned before it died. */ + requests?: StrongRef[] } /** @@ -102,6 +107,11 @@ export interface TurnResult { * exclusion makes it unreachable) — it is classified crashed with `reason: 'both_terminals'`, never * silently preferring one. `reason: 'empty'` distinguishes the produced-nothing crash so the caller * can log the right diagnostic. + * + * Commissioned sub-requests are deliberately not read here. They are not a terminal record and not a + * question: a turn that commissioned three plans and then produced nothing of its own has not + * fulfilled the request it was given, and is retried like any other empty turn — safely, because the + * ordinal-keyed rkeys make the retry adopt the same three requests rather than write three more. */ export function classifyTurnObservation(observation: TurnObservation): { outcome: TurnOutcome @@ -163,6 +173,10 @@ export interface TurnInput { /** This daemon instance's id (`instanceId(stateDir)`), mixed into the container label/name so two * daemons on one machine cannot collide on a name — or reconcile away each other's containers. */ instance?: string + /** What this turn may commission (design §7, §13), resolved by the dispatcher from the index it + * pumped. Absent — the default, and everything an operator who set nothing gets — means the turn + * socket refuses `requestArtifact` outright. */ + commission?: CommissionContext } /** Resolves a profile's declared harness name to the harness that runs it. `selectHarness` @@ -576,6 +590,9 @@ export async function runTurn(input: TurnInput, deps: TurnDeps): Promise { // --- The invariant the `answer` turn rests on, asserted where it can actually fail. --- // -// design §10: a conversation cannot sustain itself without a person writing a record each time, -// because the daemon never authors an `answer` request. Auto-review is the one daemon-authored hop -// there is. That is a property of the SOURCE — there is no input that makes the record below any -// type but `review` — so it is checked over the source rather than by a runtime branch that could -// never be reached (and so could never be covered). +// design §10/§14C: a conversation cannot sustain itself without a person writing a record each time, +// because the daemon never authors an `answer` request. That is a property of the SOURCE — there is +// no input that makes auto-review's record any type but `review` — so it is checked over the source +// rather than by a runtime branch that could never be reached (and so could never be covered). +// +// It is now TWO writers, each type-constrained, and the loosening is deliberate (design §7/§13): a +// turn may commission further targeted work through the turn socket. So the allowlist below names +// both files, and the assertions over `turn-socket.ts` pin the properties that keep §14C alive — +// `answer` and `review` refused as commissionable types, the turn's own mode refused for review and +// answer turns, the one-hop agent-author gate, the opt-in, and no `assignee` on the record. Adding a +// THIRD writer, or dropping any of those, is what this test exists to stop. describe('the daemon writes no other request', () => { const SRC = new URL('../src/', import.meta.url) @@ -1194,26 +1200,64 @@ describe('the daemon writes no other request', () => { .filter((entry) => entry.isFile() && entry.name.endsWith('.ts')) .map((entry) => ({ name: entry.name, text: readFileSync(new URL(entry.name, SRC), 'utf8') })) - it('creates an artifactRequest in exactly one place, and it is auto-review', () => { + const sourceOf = (name) => sources().find((file) => file.name === name).text + + it('creates an artifactRequest in exactly two places: auto-review and the turn socket', () => { const writers = sources().filter((file) => /\.create\(\s*COLLECTIONS\.artifactRequest/.test(file.text), ) assert.deepEqual( - writers.map((file) => file.name), - ['auto-review.ts'], - 'a second daemon-authored request would break the no-self-sustaining-conversation invariant (design §10)', + writers.map((file) => file.name).sort(), + ['auto-review.ts', 'turn-socket.ts'], + 'a third daemon-authored request would break the no-self-sustaining-conversation invariant (design §10)', ) - const matches = writers[0].text.match(/\.create\(\s*COLLECTIONS\.artifactRequest/g) ?? [] - assert.equal(matches.length, 1, 'auto-review writes exactly one artifactRequest') + for (const writer of writers) { + const matches = writer.text.match(/\.create\(\s*COLLECTIONS\.artifactRequest/g) ?? [] + assert.equal(matches.length, 1, `${writer.name} writes exactly one artifactRequest`) + } }) - it('builds that request as an open review and nothing off the wire', () => { - const text = sources().find((file) => file.name === 'auto-review.ts').text + it('builds the auto-review request as an open review and nothing off the wire', () => { + const text = sourceOf('auto-review.ts') const opens = text.indexOf('const record: ArtifactRequestRecord = {') const record = text.slice(opens, text.indexOf('\n }\n', opens)) assert.match(record, /\n\s*type: REVIEW_TYPE,\n/, 'the request literal names the review constant') assert.match(text, /^const REVIEW_TYPE = 'review'$/m) assert.doesNotMatch(record, /answer/i) - assert.doesNotMatch(record, /assignee/, 'the sole daemon-authored request is open') + assert.doesNotMatch(record, /assignee/, "auto-review's request is open") + }) + + it('builds the commissioned request open, anchored by the daemon, and never as a review or answer', () => { + const text = sourceOf('turn-socket.ts') + const opens = text.indexOf('const record: ArtifactRequestRecord = {') + const record = text.slice(opens, text.indexOf('\n }\n', opens)) + // The type is the container's, but the ANCHOR and the provenance are the daemon's, taken from + // turn context — the same rule every other record this file writes follows. + assert.match(record, /\n\s*\.\.\.anchor,\n/, "the record takes the turn request's own anchor") + assert.match(record, /\n\s*commissionedBy,\n/, 'the record names the request that commissioned it') + assert.doesNotMatch(record, /assignee/, 'a commissioned request is open; claims decide who runs it') + }) + + it('refuses review and answer as commissionable types, and refuses them as commissioning turns', () => { + const text = sourceOf('turn-socket.ts') + const handler = text.slice(text.indexOf('async #requestArtifact('), text.indexOf('async #submitAnswer(')) + assert.match( + handler, + /if \(type === REVIEW_TYPE_NAME \|\| type === ANSWER_TYPE_NAME\)/, + 'neither built-in type may be commissioned by a turn (design §7, §14C)', + ) + assert.match(handler, /mode\.kind === 'review'/, 'a verdict never commissions further work') + assert.match(handler, /mode\.kind === 'answer'/, 'a reply never commissions the next reply') + }) + + it("gates commissioning on the operator's opt-in and on the one-hop bound", () => { + const text = sourceOf('turn-socket.ts') + const handler = text.slice(text.indexOf('async #requestArtifact('), text.indexOf('async #submitAnswer(')) + assert.match(handler, /if \(!commission\)/, 'no commission context means the operator did not opt in') + assert.match( + handler, + /commission\.commissionerIsAgent/, + 'an agent-authored request commissions nothing: the fan-out is one hop deep', + ) }) }) diff --git a/packages/daemon/test/cli.test.mjs b/packages/daemon/test/cli.test.mjs index cf4bbad..c55e106 100644 --- a/packages/daemon/test/cli.test.mjs +++ b/packages/daemon/test/cli.test.mjs @@ -46,6 +46,17 @@ it('resolveRunConfig defaults merge poll timing and clamps invalid values', () = assert.equal(clamped.mergePollIntervalMs, 120_000) assert.equal(clamped.mergePollBackoffMaxMs, 120_000) }) +it('resolveRunConfig defaults agent-authored requests to off-for-authoring, on-for-dispatch', () => { + const SPACE = 'at://did:plc:human/com.disnetdev.radial.space/space1' + // The §13 posture for an operator who configured nothing: their containers commission nothing, and + // their daemon keeps running agent-authored requests exactly as it has since auto-review. + assert.deepEqual(resolveRunConfig({ spaces: [SPACE] }).agentRequests, { author: false, dispatch: true, max: 10 }) + assert.deepEqual( + resolveRunConfig({ spaces: [SPACE], agentRequests: { author: true, dispatch: false, max: 3 } }).agentRequests, + { author: true, dispatch: false, max: 3 }, + ) +}) + it('run.stateDir defaults under the resolved data directory, and an explicit one still wins', () => { // One setting separates everything an instance owns: point `dataDir` somewhere and the ledgers, // checkpoints and per-turn directories follow it, with no second thing to remember. diff --git a/packages/daemon/test/config.test.mjs b/packages/daemon/test/config.test.mjs index b3d4ac4..4acb9ba 100644 --- a/packages/daemon/test/config.test.mjs +++ b/packages/daemon/test/config.test.mjs @@ -455,6 +455,30 @@ it('refuses a claim configuration that cannot survive two failed renewals', () = assert.throws(() => parseRunConfig({ spaces: [SPACE], claims: 'yes' }), /must be an object/) }) +// --- run.agentRequests: the two halves of agent-authored work (design §7, §13) --------------- +// +// `author` is a capability THIS operator's containers gain, so it is off unless asked for; `dispatch` +// is work this operator's budget pays for, which has been true since auto-review, so it is on. + +it('parses run.agentRequests and defaults the whole block away when absent', () => { + assert.equal(parseRunConfig({ spaces: [SPACE] }).agentRequests, undefined) + assert.deepEqual( + parseRunConfig({ spaces: [SPACE], agentRequests: { author: true, dispatch: false, max: 3 } }).agentRequests, + { author: true, dispatch: false, max: 3 }, + ) + assert.deepEqual(parseRunConfig({ spaces: [SPACE], agentRequests: { author: true } }).agentRequests, { + author: true, + }) +}) + +it('refuses an agentRequests cap above the per-turn ceiling rather than clamping it silently', () => { + assert.throws(() => parseRunConfig({ spaces: [SPACE], agentRequests: { max: 100 } }), /per-turn ceiling/) + assert.throws(() => parseRunConfig({ spaces: [SPACE], agentRequests: { max: 0 } }), /at least 1/) + assert.throws(() => parseRunConfig({ spaces: [SPACE], agentRequests: { max: 2.5 } }), /at least 1/) + assert.throws(() => parseRunConfig({ spaces: [SPACE], agentRequests: { author: 'yes' } }), /run\.agentRequests\.author/) + assert.throws(() => parseRunConfig({ spaces: [SPACE], agentRequests: true }), /must be an object/) +}) + it('parses run.jetstream and insists on a ws(s) endpoint', () => { assert.equal(parseRunConfig({ spaces: [SPACE] }).jetstream, undefined) assert.deepEqual( diff --git a/packages/daemon/test/dispatch.test.mjs b/packages/daemon/test/dispatch.test.mjs index 0cb5e93..f304066 100644 --- a/packages/daemon/test/dispatch.test.mjs +++ b/packages/daemon/test/dispatch.test.mjs @@ -989,6 +989,61 @@ it('rejects a request the ledger considers ineligible: running, cooling down, an gaveUp.close() }) +// --- Agent-authored requests: local dispatch policy (design §7, §13) -------- +// +// Any active member may author a request, agent or human — that is the protocol (design §3), and +// auto-review has relied on it since it shipped. What is new is a per-OPERATOR knob for an operator +// who does not want to spend their model budget on another operator's agents' fan-out. It is local +// policy: it changes nothing convergent, and a request this daemon declines stays open for whoever +// will take it. + +it('dispatches an agent-authored request by default', () => { + const base = buildScenario() + // The same request, authored by the AGENT rather than the human — an auto-review request, or one a + // turn commissioned. + const records = base.records.filter((r) => r.uri !== base.request.uri) + const request = mk(AGENT, COLLECTIONS.artifactRequest, 'request-agent', 'cid-request-agent', { + ...base.request.value, + commissionedBy: { uri: 'at://did:plc:human/com.disnetdev.radial.artifactRequest/parent', cid: 'parentcid' }, + }) + const index = materialize(store([...records, request]), { spaceUri: SPACE_URI }) + const ledger = new TurnLedger() + const actors = registryFor([actorFor(AGENT)]) + assert.deepEqual(uris(selectDispatchable(index, actors, ledger)), [request.uri]) + // ...and `commissionedBy` changed nothing about how it folded or dispatched. + assert.equal(selectDispatchable(index, actors, ledger)[0].artifactType.name, 'plan') + ledger.close() +}) + +it('refuses an agent-authored request under run.agentRequests.dispatch: false, and says why', () => { + const base = buildScenario() + const records = base.records.filter((r) => r.uri !== base.request.uri) + const request = mk(AGENT, COLLECTIONS.artifactRequest, 'request-agent', 'cid-request-agent', base.request.value) + const index = materialize(store([...records, request]), { spaceUri: SPACE_URI }) + const ledger = new TurnLedger() + const actors = registryFor([actorFor(AGENT)]) + const rejections = [] + const result = selectDispatchable(index, actors, ledger, undefined, (uri, reason) => rejections.push([uri, reason]), new Set(), { + allowAgentAuthored: false, + }) + assert.deepEqual(result, []) + assert.equal(rejections.length, 1) + assert.match(rejections[0][1], /run\.agentRequests\.dispatch is false/) + ledger.close() +}) + +it('keeps dispatching HUMAN-authored requests under run.agentRequests.dispatch: false', () => { + const { records, request } = buildScenario() + const index = materialize(store(records), { spaceUri: SPACE_URI }) + const ledger = new TurnLedger() + const actors = registryFor([actorFor(AGENT)]) + assert.deepEqual( + uris(selectDispatchable(index, actors, ledger, undefined, undefined, new Set(), { allowAgentAuthored: false })), + [request.uri], + ) + ledger.close() +}) + // --- Implementation dispatch (generalized type + v2 predecessor) ------------ const IMPL_TYPE = mk(ROOT, COLLECTIONS.artifactType, 'type-impl', 'cid-type-impl', { diff --git a/packages/daemon/test/harness.test.mjs b/packages/daemon/test/harness.test.mjs index 60e949c..aeb76f7 100644 --- a/packages/daemon/test/harness.test.mjs +++ b/packages/daemon/test/harness.test.mjs @@ -328,7 +328,13 @@ it('every harness hands its CLI the same prompt, byte for byte', () => { const claude = new ClaudeCodeHarness() const pi = new PiHarness() const codex = new CodexHarness() - for (const variant of [{}, { implementation: true }, { review: true }, { implementation: true, forge: 'tangled' }]) { + for (const variant of [ + {}, + { implementation: true }, + { review: true }, + { implementation: true, forge: 'tangled' }, + { commission: { max: 4 } }, + ]) { const input = { briefPath: '/bundle/brief.md', bundleDir: '/bundle', workdir: '/work', models: [], ...variant } const expected = buildPrompt(input) assert.equal(claude.invocation(input).argv[2], expected) @@ -337,6 +343,31 @@ it('every harness hands its CLI the same prompt, byte for byte', () => { } }) +// --- Commissioning further work (design §7, §13) --------------------------------------------- + +it('says nothing about commissioning unless this turn may commission', () => { + // The prompt must not offer a command the socket would refuse: an operator who left + // `run.agentRequests.author` alone gets a turn that never learns the verb exists. + const base = { briefPath: '/bundle/brief.md', bundleDir: '/bundle', workdir: '/work', models: [] } + for (const variant of [{}, { implementation: true }, { implementation: true, forge: 'tangled' }]) { + assert.doesNotMatch(buildPrompt({ ...base, ...variant }), /request create/) + const offered = buildPrompt({ ...base, ...variant, commission: { max: 4 } }) + assert.match(offered, /radial request create --type /) + assert.match(offered, /up to 4 request\(s\)/, 'the cap is stated, not left for the agent to discover') + assert.match(offered, /one hop/, 'so is the bound on what it commissions') + assert.match(offered, /still owe exactly one/, 'commissioning never replaces the terminal record') + } +}) + +it('never offers commissioning to a review or an answer turn, even when the flag is passed', () => { + // Belt and braces: turn.ts does not pass `commission` for these, and the socket refuses them + // outright. This pins the prompt half, since a prompt is what an agent actually reads. + const base = { briefPath: '/bundle/brief.md', bundleDir: '/bundle', workdir: '/work', models: [] } + for (const variant of [{ review: true }, { answer: true }]) { + assert.doesNotMatch(buildPrompt({ ...base, ...variant, commission: { max: 4 } }), /request create/) + } +}) + // --- PiHarness ----------------------------------------------------------------------------- it('PiHarness runs one non-interactive, sessionless, project-untrusting turn', () => { diff --git a/packages/daemon/test/private-dispatch.test.mjs b/packages/daemon/test/private-dispatch.test.mjs index 92dded4..3430e72 100644 --- a/packages/daemon/test/private-dispatch.test.mjs +++ b/packages/daemon/test/private-dispatch.test.mjs @@ -10,6 +10,7 @@ // PDS client that throws on every method, whose only job is to prove that nothing reached it. import assert from 'node:assert/strict' +import { connect } from 'node:net' import { mkdtemp } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -27,6 +28,7 @@ import { import { ClaimLedger } from '../dist/claim-ledger.js' import { ClaimManager } from '../dist/claims.js' import { TurnLedger } from '../dist/ledger.js' +import { TurnSocketServer } from '../dist/turn-socket.js' import { selectDispatchable } from '../dist/dispatch.js' import { PrivateSpaceRuntime, @@ -342,6 +344,68 @@ describe('a daemon serving a private space', () => { } }) + // Commissioning is the same substitution one more time: the turn socket writes through the actor + // client it was handed, and in a private space that client is an `EnvelopeWriter`. Nothing in + // `turn-socket.ts` knows which — the point of this test is that it does not have to. + it('commissions a sub-request through the turn socket, sealed and folded by the peer', async () => { + const { a, b, close } = await setup() + const directory = await mkdtemp(join(tmpdir(), 'radial-private-commission-')) + let server + try { + for (let tick = 0; tick < 3; tick += 1) await a.tick() + const [item] = a.dispatchable() + const goal = { uri: item.target.target.uri, cid: item.target.target.cid } + server = new TurnSocketServer(join(directory, 'turn.sock'), { + token: 'tok', + request: { uri: item.request.uri, cid: item.request.cid, goal }, + client: a.registry.select(a.did, 'plan', SPACE_URI).client, + mode: { kind: 'artifact', type: 'plan' }, + now: () => new Date(T0).toISOString(), + commission: { + commissionerIsAgent: false, + resolveType: (type) => (type === 'plan' ? { name: 'plan', scope: 'goal' } : undefined), + resolveArtifact: () => undefined, + }, + }) + await server.listen() + const response = await new Promise((resolve, reject) => { + const socket = connect(join(directory, 'turn.sock')) + let data = '' + socket.on('connect', () => + socket.write(`${JSON.stringify({ method: 'requestArtifact', token: 'tok', type: 'plan', brief: 'split this in two' })}\n`), + ) + socket.on('data', (chunk) => { + data += chunk.toString('utf8') + }) + socket.on('end', () => resolve(JSON.parse(data))) + socket.on('error', reject) + }) + assert.equal(response.ok, true) + + // Sealed, not posted: the record CID the socket returned is an envelope's own. + const envelope = a.runtime.envelopes.all().find((entry) => entry.recordCid === response.ref.cid) + assert.ok(envelope, 'the commissioned request was not sealed into an envelope') + assert.equal(envelope.did, AGENT_A) + + // And the peer folds it out of the bus, as an open request of the same goal. + await a.tick() + await b.tick() + const view = b.index().goals[0] + const commissioned = view.requests.find((request) => request.uri === response.ref.uri) + assert.ok(commissioned, 'the peer did not fold the commissioned request') + assert.deepEqual(commissioned.value.commissionedBy, { uri: item.request.uri, cid: item.request.cid }) + assert.equal(commissioned.value.assignee, undefined) + assert.equal( + indexDigest(a.index()).overall, + indexDigest(b.index()).overall, + 'two replicas holding the same envelopes must fold the same index', + ) + } finally { + await server?.close() + close() + } + }) + it('resolves a two-daemon claim race by the existing deterministic tie-break', async () => { const { corpus, a, b, close } = await setup() try { diff --git a/packages/daemon/test/turn-socket.test.mjs b/packages/daemon/test/turn-socket.test.mjs index be48dab..120d1cd 100644 --- a/packages/daemon/test/turn-socket.test.mjs +++ b/packages/daemon/test/turn-socket.test.mjs @@ -7,7 +7,7 @@ import { it } from 'node:test' import { CredentialClient, createSession } from '../../atproto/dist/index.js' import { COLLECTIONS } from '../../core/dist/index.js' import { LocalPds } from '../../atproto/test/local-pds.mjs' -import { TurnSocketServer, answerRkey, implArtifactRkey, planArtifactRkey, reviewRkey } from '../dist/index.js' +import { TurnSocketServer, answerRkey, implArtifactRkey, planArtifactRkey, reviewRkey, turnRequestRkey } from '../dist/index.js' const REQUEST = { uri: 'at://did:plc:human/com.disnetdev.radial.artifactRequest/req1', @@ -1297,3 +1297,335 @@ it('a GitHub submit with no --pr is still rejected: only a brokering forge relax } }) }) + +// ── requestArtifact: a turn commissioning further targeted work (design §7, §13) ─────────────── +// +// Everything here is about what the daemon owns versus what the container supplies. The container +// says which type of work and why; the anchor, the openness, the provenance backref and the rkey are +// the daemon's, from turn context — and four refusals (mode, opt-in, one-hop, built-in types) are +// what keep the fan-out bounded and design §14C intact. + +const PLAN_TYPE = { name: 'plan', scope: 'goal', description: 'A plan', outputDescription: 'md', createdAt: NOW } +const ADR_TYPE = { name: 'adr', scope: 'project', description: 'An ADR', outputDescription: 'md', createdAt: NOW } +const COMMISSIONABLE = { + commissionerIsAgent: false, + resolveType: (type) => (type === 'plan' ? PLAN_TYPE : type === 'adr' ? ADR_TYPE : undefined), + resolveArtifact: (locator) => + locator === BASED_ON_ARTIFACT.uri + ? BASED_ON_ARTIFACT + : locator === `${BASED_ON_ARTIFACT.uri}#${BASED_ON_ARTIFACT.cid}` + ? BASED_ON_ARTIFACT + : undefined, +} +const requests = (pds) => [...pds.records.values()].filter((r) => r.value.$type === COLLECTIONS.artifactRequest) + +it('requestArtifact writes an open request anchored to the turn request, naming its commissioner', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context: { commission: COMMISSIONABLE } }) + try { + const response = await sendLine(socketPath, { + method: 'requestArtifact', + token: TOKEN, + type: 'plan', + brief: ' the sub-brief ', + basedOn: [BASED_ON_ARTIFACT.uri], + }) + assert.equal(response.ok, true) + assert.equal(response.ref.uri, `at://${pds.did}/${COLLECTIONS.artifactRequest}/${turnRequestRkey(REQUEST.uri, REQUEST.cid, 0)}`) + + const stored = requests(pds) + assert.equal(stored.length, 1) + const record = stored[0].value + assert.deepEqual(record.goal, REQUEST.goal, 'anchored to the commissioning request’s own goal') + assert.equal(record.project, undefined) + assert.equal(record.type, 'plan') + assert.equal(record.brief, 'the sub-brief') + assert.deepEqual(record.basedOn, [BASED_ON_ARTIFACT], 'refs are resolved daemon-side, never taken off the wire') + assert.deepEqual(record.commissionedBy, { uri: REQUEST.uri, cid: REQUEST.cid }) + assert.equal(record.assignee, undefined, 'a commissioned request is open; claims arbitrate') + assert.equal(record.createdAt, NOW) + assert.deepEqual(server.observation.requests, [response.ref]) + assert.equal(server.observation.artifact, undefined, 'commissioning is not a terminal record') + } finally { + await server.close() + } + }) +}) + +it('requestArtifact still permits the turn to submit its own artifact afterwards', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-then-submit') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context: { commission: COMMISSIONABLE } }) + try { + assert.equal((await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'more work' })).ok, true) + const submitted = await sendLine(socketPath, { method: 'submitArtifact', token: TOKEN, title: 'A short title', body: 'the plan' }) + assert.equal(submitted.ok, true) + assert.equal(server.observation.requests.length, 1) + assert.deepEqual(server.observation.artifact, submitted.ref) + } finally { + await server.close() + } + }) +}) + +it('requestArtifact is refused without an opt-in, and writes nothing', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-off') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client) + try { + const response = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'more work' }) + assert.equal(response.ok, false) + assert.match(response.error, /run\.agentRequests\.author/) + assert.equal(pds.records.size, 0) + } finally { + await server.close() + } + }) +}) + +it('requestArtifact is refused when the commissioning request was authored by an agent (one hop)', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-hop') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { + context: { commission: { ...COMMISSIONABLE, commissionerIsAgent: true } }, + }) + try { + const response = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'and another round' }) + assert.equal(response.ok, false) + assert.match(response.error, /one hop deep/) + assert.equal(pds.records.size, 0) + } finally { + await server.close() + } + }) +}) + +for (const [kind, context] of [ + ['review', { ...REVIEW_MODE, commission: COMMISSIONABLE }], + [ + 'answer', + { + commission: COMMISSIONABLE, + mode: { + kind: 'answer', + subject: { uri: 'at://did:plc:human/com.disnetdev.radial.message/q', cid: 'qcid' }, + anchor: { goal: REQUEST.goal }, + subjectAuthor: 'did:plc:human', + }, + }, + ], +]) { + it(`requestArtifact is refused on a ${kind} turn, opt-in or not`, async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds(`did:plc:agent-commission-${kind}`) + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context }) + try { + const response = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'more work' }) + assert.equal(response.ok, false) + assert.match(response.error, /never commissions further work/) + assert.equal(pds.records.size, 0) + } finally { + await server.close() + } + }) + }) +} + +for (const type of ['review', 'answer']) { + it(`requestArtifact refuses to commission a '${type}' request`, async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds(`did:plc:agent-commission-type-${type}`) + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context: { commission: COMMISSIONABLE } }) + try { + const response = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type, brief: 'judge this' }) + assert.equal(response.ok, false) + assert.match(response.error, /not commissionable by a turn/) + assert.equal(pds.records.size, 0) + } finally { + await server.close() + } + }) + }) +} + +it('requestArtifact refuses an unregistered type and a type of the wrong scope', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-scope') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context: { commission: COMMISSIONABLE } }) + try { + const unknown = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'haiku', brief: 'x' }) + assert.equal(unknown.ok, false) + assert.match(unknown.error, /not a registered artifact type/) + // `adr` is project-scoped and this turn's request is anchored to a goal. + const scoped = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'adr', brief: 'x' }) + assert.equal(scoped.ok, false) + assert.match(scoped.error, /project-scoped and this turn's request is anchored to a goal/) + assert.equal(pds.records.size, 0) + } finally { + await server.close() + } + }) +}) + +it('requestArtifact refuses a based-on ref this space has no artifact for', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-refs') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context: { commission: COMMISSIONABLE } }) + try { + const response = await sendLine(socketPath, { + method: 'requestArtifact', + token: TOKEN, + type: 'plan', + brief: 'x', + basedOn: [BASED_ON_ARTIFACT.uri, 'at://did:plc:elsewhere/com.disnetdev.radial.artifact/nope'], + }) + assert.equal(response.ok, false) + assert.match(response.error, /does not name an artifact of this space/) + assert.equal(pds.records.size, 0, 'a bad ref refuses the whole request rather than dropping the ref') + } finally { + await server.close() + } + }) +}) + +it('requestArtifact refuses a blank brief and caps how many one turn may commission', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-cap') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { + context: { commission: { ...COMMISSIONABLE, max: 2 } }, + }) + try { + const blank = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: ' ' }) + assert.equal(blank.ok, false) + assert.match(blank.error, /non-blank --brief/) + + for (const ordinal of [0, 1]) { + const ok = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: `part ${ordinal}` }) + assert.equal(ok.ok, true) + assert.equal(ok.ref.uri.endsWith(turnRequestRkey(REQUEST.uri, REQUEST.cid, ordinal)), true, 'ordinal-keyed rkeys') + } + const capped = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'one too many' }) + assert.equal(capped.ok, false) + assert.match(capped.error, /at most 2 requests/) + assert.equal(requests(pds).length, 2) + } finally { + await server.close() + } + }) +}) + +it('requestArtifact refuses a brief over the lexicon ceiling', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-long') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context: { commission: COMMISSIONABLE } }) + try { + const response = await sendLine(socketPath, { + method: 'requestArtifact', + token: TOKEN, + type: 'plan', + brief: 'x'.repeat(30_001), + }) + assert.equal(response.ok, false) + assert.match(response.error, /brief exceeds 30000 characters/) + assert.equal(pds.records.size, 0) + } finally { + await server.close() + } + }) +}) + +it('requestArtifact ignores anchors on the wire: a goal/project in the envelope changes nothing', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-wire') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { context: { commission: COMMISSIONABLE } }) + try { + const response = await sendLine(socketPath, { + method: 'requestArtifact', + token: TOKEN, + type: 'plan', + brief: 'x', + goal: { uri: 'at://did:plc:someone/com.disnetdev.radial.goal/other', cid: 'othercid' }, + project: PROJECT, + assignee: 'did:plc:someone', + commissionedBy: { uri: 'at://forged', cid: 'forged' }, + }) + assert.equal(response.ok, true) + const record = requests(pds)[0].value + assert.deepEqual(record.goal, REQUEST.goal) + assert.equal(record.project, undefined) + assert.equal(record.assignee, undefined) + assert.deepEqual(record.commissionedBy, { uri: REQUEST.uri, cid: REQUEST.cid }) + } finally { + await server.close() + } + }) +}) + +it('a retried turn adopts its own nth request, and refuses one that commissioned a different type', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-retry') + const client = await makeClient(pds) + const context = { commission: COMMISSIONABLE } + + const first = await startServer(directory, client, { context }) + const firstResponse = await sendLine(first.socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'first prose' }) + await first.server.close() + assert.equal(firstResponse.ok, true) + + // The retry writes a DIFFERENT brief for the same ordinal — legitimate, and deliberately not + // compared, exactly as a retried review's findings are not. + const second = await startServer(directory, client, { context }) + const secondResponse = await sendLine(second.socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'reworded prose' }) + await second.server.close() + assert.equal(secondResponse.ok, true) + assert.deepEqual(secondResponse.ref, firstResponse.ref) + assert.equal(requests(pds).length, 1) + assert.equal(requests(pds)[0].value.brief, 'first prose', 'adoption keeps the record that landed') + + // A retry that commissions a different TYPE at the same ordinal is a different request: refuse. + const third = await startServer(directory, client, { + context: { commission: { ...COMMISSIONABLE, resolveType: () => PLAN_TYPE } }, + }) + const thirdResponse = await sendLine(third.socketPath, { method: 'requestArtifact', token: TOKEN, type: 'design', brief: 'something else' }) + await third.server.close() + assert.equal(thirdResponse.ok, false) + assert.match(thirdResponse.error, /not the one this turn wrote/) + assert.equal(requests(pds).length, 1) + }) +}) + +it('a project-scoped turn commissions project-scoped work anchored to its project', async () => { + await withTempDir(async (directory) => { + const pds = new LocalPds('did:plc:agent-commission-project') + const client = await makeClient(pds) + const { server, socketPath } = await startServer(directory, client, { + context: { request: PROJECT_REQUEST, mode: { kind: 'artifact', type: 'adr' }, commission: COMMISSIONABLE }, + }) + try { + const rejected = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'plan', brief: 'x' }) + assert.equal(rejected.ok, false) + assert.match(rejected.error, /goal-scoped and this turn's request is anchored to a project/) + + const response = await sendLine(socketPath, { method: 'requestArtifact', token: TOKEN, type: 'adr', brief: 'record the decision' }) + assert.equal(response.ok, true) + const record = requests(pds)[0].value + assert.deepEqual(record.project, PROJECT_REQUEST.project) + assert.equal(record.goal, undefined) + assert.deepEqual(record.commissionedBy, { uri: PROJECT_REQUEST.uri, cid: PROJECT_REQUEST.cid }) + } finally { + await server.close() + } + }) +}) diff --git a/packages/lexicons/README.md b/packages/lexicons/README.md index 57ceb26..badc5c0 100644 --- a/packages/lexicons/README.md +++ b/packages/lexicons/README.md @@ -64,6 +64,35 @@ Nothing infers a title from the body's Markdown at write time. An explicit value chosen by whoever wrote the artifact is what keeps the signed field deliberate, and keeps two implementations from disagreeing about what a record is called. +## `artifactRequest.commissionedBy` is provenance, and no fold rule reads it + +`com.disnetdev.radial.artifactRequest` carries an optional `commissionedBy`: a +strongRef to the request whose *turn* wrote this one. An agent running a plan or +implementation turn may commission further targeted work — "this chunk needs +three plans" — through the turn socket (design §13), and this field records where +that request came from. + +Additive on the same terms as `artifact.title`: it is not `required` and must not +become required, because every request signed before the field existed carries +none, and a materializer must ignore a record that fails validation. + +Three things a second implementation should know: + +- **Nothing branches on it.** `materialize()` reads it not at all: a request with + `commissionedBy` folds exactly as the same request without it, and the digest is + unchanged. It exists for provenance in a UI, for a bundle, and so a retried turn + can recognize the request it wrote last time. +- **It is not the iteration bound.** The bound on agent-commissioned work is + enforced by the *writer*, at the socket, against the fold's agent-member set: + a turn whose own request was agent-authored may not commission anything, so the + fan-out is one hop deep. A bound expressed as a fold rule would make old and new + materializers disagree about the same corpus; a bound expressed at the write path + cannot. Omitting the field therefore launders nothing — a writer that dropped it + would gain no depth. +- **It is not an authorization.** Any active member may author a request, agent or + human (design §3), which was already true; `commissionedBy` neither widens that + nor narrows it. + ## An agent's `scopes` overrides `artifactTypes` per space, and a writer owes both `com.disnetdev.radial.agent` carries an optional `scopes`: a list of diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.artifactRequest.json b/packages/lexicons/lexicons/com.disnetdev.radial.artifactRequest.json index b0844ff..5c8b4f7 100644 --- a/packages/lexicons/lexicons/com.disnetdev.radial.artifactRequest.json +++ b/packages/lexicons/lexicons/com.disnetdev.radial.artifactRequest.json @@ -15,6 +15,7 @@ "subject": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, "basedOn": {"type": "array", "maxLength": 100, "items": {"type": "ref", "ref": "com.atproto.repo.strongRef"}}, "assignee": {"type": "string", "format": "did"}, + "commissionedBy": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, "brief": {"type": "string", "maxLength": 30000}, "autoReview": {"type": "boolean"}, "createdAt": {"type": "string", "format": "datetime"} diff --git a/packages/sidecar/src/cli.ts b/packages/sidecar/src/cli.ts index 5c77970..95e46b0 100644 --- a/packages/sidecar/src/cli.ts +++ b/packages/sidecar/src/cli.ts @@ -179,7 +179,13 @@ export const help = `radial — human CLI for Radial records Inside a turn container the allowlist is narrower and the daemon anchors every record: "artifact submit", "review submit", "answer submit" (the reply an answer -turn was commissioned for) and "message post" (the can't-complete question). +turn was commissioned for), "message post" (the can't-complete question), and +"request create --type TYPE (--brief TEXT | --brief-file PATH) [--based-on REF]..." +— further targeted work this turn is commissioning. The daemon anchors it to this +turn's own goal/project and writes it open, so --goal/--project/--subject/--assignee +are refused there; the operator must have enabled it (run.agentRequests.author), a +turn whose own request an agent wrote may not commission at all, and review and +answer requests are never commissionable. References may be bare at:// URIs or pinned at://...#CID locators. Use --profile NAME to select an actor and --json for structured output. diff --git a/packages/sidecar/src/socket.ts b/packages/sidecar/src/socket.ts index 411bbe5..350d0df 100644 --- a/packages/sidecar/src/socket.ts +++ b/packages/sidecar/src/socket.ts @@ -28,7 +28,8 @@ async function readBody(args: string[], readText: (path: string) => Promise') + } + const inline = lastValue(args, '--brief') + const path = lastValue(args, '--brief-file') + if (Boolean(inline) === Boolean(path)) { + throw new Error('Provide exactly one of --brief or --brief-file') + } + const brief = inline !== undefined ? inline : await readText(path as string) + // Locators, not resolved refs: this process holds no credentials and no index. The daemon + // resolves each against the space index it already has, and refuses one it cannot. + const basedOn = allValues(args, '--based-on') + return { + method: 'requestArtifact', + token, + type, + brief, + ...(basedOn.length ? { basedOn } : {}), + } + } + if (group === 'message' && action === 'post') { // In socket mode a message is always the can't-complete question; the daemon anchors it to // the turn's goal/artifact, so --goal/--artifact/--re (used in direct-PDS mode) are ignored. @@ -121,7 +162,7 @@ export async function buildTurnRpc( const command = args.slice(0, 2).filter((part) => part !== undefined).join(' ') || '(none)' throw new Error( - `In turn socket mode only "artifact submit", "review submit", "answer submit", and "message post" are permitted, got: ${command}`, + `In turn socket mode only "artifact submit", "review submit", "answer submit", "request create", and "message post" are permitted, got: ${command}`, ) } diff --git a/packages/sidecar/test/image-create.test.mjs b/packages/sidecar/test/image-create.test.mjs index 9a395dc..cf55388 100644 --- a/packages/sidecar/test/image-create.test.mjs +++ b/packages/sidecar/test/image-create.test.mjs @@ -94,7 +94,7 @@ describe('image create', () => { // nothing else, so this needs no guard of its own. await assert.rejects( () => buildTurnRpc(['image', 'create', '--blob', JSON.stringify(BLOB)], 'token', async () => ''), - /only "artifact submit", "review submit", "answer submit", and "message post" are permitted/, + /only "artifact submit", "review submit", "answer submit", "request create", and "message post" are permitted/, ) }) }) diff --git a/packages/sidecar/test/socket.test.mjs b/packages/sidecar/test/socket.test.mjs index 137786c..28bb9ef 100644 --- a/packages/sidecar/test/socket.test.mjs +++ b/packages/sidecar/test/socket.test.mjs @@ -197,6 +197,60 @@ describe('buildTurnRpc mapping', () => { ) }) + it('maps request create to requestArtifact with an inline brief', async () => { + const rpc = await buildTurnRpc(['request', 'create', '--type', 'plan', '--brief', 'split this up'], 'tok', readText) + assert.deepEqual(rpc, { method: 'requestArtifact', token: 'tok', type: 'plan', brief: 'split this up' }) + }) + + it('reads a request brief from --brief-file and collects repeated --based-on', async () => { + const calls = [] + const rt = async (path) => { + calls.push(path) + return 'the brief, from a file' + } + const rpc = await buildTurnRpc( + ['request', 'create', '--type', 'plan', '--brief-file', '/tmp/brief.md', '--based-on', 'at://a/b/c', '--based-on', 'at://d/e/f#cid1'], + 'tok', + rt, + ) + assert.deepEqual(rpc, { + method: 'requestArtifact', + token: 'tok', + type: 'plan', + brief: 'the brief, from a file', + basedOn: ['at://a/b/c', 'at://d/e/f#cid1'], + }) + assert.deepEqual(calls, ['/tmp/brief.md']) + }) + + it('requires --type and exactly one of --brief/--brief-file', async () => { + await assert.rejects(buildTurnRpc(['request', 'create', '--brief', 'x'], 'tok', readText), /requires --type/) + await assert.rejects( + buildTurnRpc(['request', 'create', '--type', 'plan'], 'tok', readText), + /exactly one of --brief or --brief-file/, + ) + await assert.rejects( + buildTurnRpc(['request', 'create', '--type', 'plan', '--brief', 'x', '--brief-file', '/tmp/b'], 'tok', readText), + /exactly one of --brief or --brief-file/, + ) + }) + + // Refused rather than ignored, the `message post --parent` precedent: a turn that thought it had + // assigned or re-anchored the work it commissioned would be wrong about a record it cannot see. + for (const [flag, value] of [ + ['--goal', 'at://x/y/z'], + ['--project', 'at://x/y/z'], + ['--subject', 'at://x/y/z'], + ['--assignee', 'did:plc:someone'], + ]) { + it(`rejects request create with ${flag} in turn mode (the daemon owns it)`, async () => { + await assert.rejects( + buildTurnRpc(['request', 'create', '--type', 'plan', '--brief', 'x', flag, value], 'tok', readText), + new RegExp(`must not pass ${flag}`), + ) + }) + } + for (const args of [ ['auth', 'login', '--profile', 'p'], ['artifact', 'post', '--request', 'at://x/y/z'], @@ -207,7 +261,7 @@ describe('buildTurnRpc mapping', () => { it(`rejects the disallowed command: ${args.join(' ')}`, async () => { await assert.rejects( buildTurnRpc(args, 'tok', readText), - /In turn socket mode only "artifact submit", "review submit", "answer submit", and "message post" are permitted/, + /In turn socket mode only "artifact submit", "review submit", "answer submit", "request create", and "message post" are permitted/, ) }) } -- 2.51.2