The link (spec 3.4.0): capabilities progress, tree and agents; the team event carries the tree; _lembas/team, /transcript, /findings, /stop; progress and tree in status; subagent on an approval; an agent chat's Min/Max and said read per turn
ci / check (push) Waiting to run
ci / check (push) Waiting to run
acp.md: a turn without agents keeps the device's own setting (an older server), as before 3.4.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+324
-11
No files matched your search
+2
-2
@@ -271,8 +271,8 @@ subagents for the chat (the chat says so beside the setting).
|
|||||||
`_lembas/configure` when the person changes it between turns — the chat's setting as the server
|
`_lembas/configure` when the person changes it between turns — the chat's setting as the server
|
||||||
keeps it (`team.md` §2: 1 ≤ min ≤ max ≤ the instance's cap). The device takes the value as the
|
keeps it (`team.md` §2: 1 ≤ min ≤ max ≤ the instance's cap). The device takes the value as the
|
||||||
session's setting for that turn: a change reaches the main agent as `team.md` §10's note, and
|
session's setting for that turn: a change reaches the main agent as `team.md` §10's note, and
|
||||||
nothing else reads the device's own configuration for the chat. Absent (an older server): `{ min:
|
nothing else reads the device's own configuration for the chat. Absent (an older server, or a
|
||||||
1, max: 1 }` — no subagents. A value the device's own global cap is under is read as `team.md` §2
|
client that is not LLeMbas): the device's own setting, as before 3.4.0. A value the device's own global cap is under is read as `team.md` §2
|
||||||
says (lowered, never refused).
|
says (lowered, never refused).
|
||||||
- `subagents: false` in the same `_meta.lembas` when the instance has subagents switched off: the
|
- `subagents: false` in the same `_meta.lembas` when the instance has subagents switched off: the
|
||||||
device offers none whatever `agents` says.
|
device offers none whatever `agents` says.
|
||||||
|
|||||||
+49
-2
@@ -42,7 +42,7 @@ import { CWD_META, REMOTE_META, Store } from "../session/store.ts"
|
|||||||
import type { Message } from "../provider/types.ts"
|
import type { Message } from "../provider/types.ts"
|
||||||
import type { Hub } from "./hub.ts"
|
import type { Hub } from "./hub.ts"
|
||||||
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
import { INVALID_PARAMS, METHOD_NOT_FOUND, RpcError, type Peer } from "./rpc.ts"
|
||||||
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs } from "./jobs.ts"
|
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, storedJobs, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
|
||||||
import { isNote } from "../session/prefix.ts"
|
import { isNote } from "../session/prefix.ts"
|
||||||
|
|
||||||
export const ACP_VERSION = 1
|
export const ACP_VERSION = 1
|
||||||
@@ -273,6 +273,12 @@ export const CAPABILITIES = [
|
|||||||
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
|
// Harness spec 3.0.0: the show tool, offered in a session the client opened when the client
|
||||||
// said it can show files; the device sends the file with `_lembas/show`.
|
// said it can show files; the device sends the file with `_lembas/show`.
|
||||||
"show",
|
"show",
|
||||||
|
// Harness spec 3.4.0: the progress phases (the `phase` event, `progress` in status); the subagent
|
||||||
|
// tree (the `team` event with the tree, `_lembas/team*`, `subagent` on an approval); the chat's
|
||||||
|
// Subagents setting read from `_meta.lembas.agents` (an agent chat's subagents are this device's).
|
||||||
|
"progress",
|
||||||
|
"tree",
|
||||||
|
"agents",
|
||||||
] as const
|
] as const
|
||||||
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
export const STOP: Record<string, string> = { stop: "end_turn", steps: "max_turn_requests", budget: "max_tokens", cancelled: "cancelled", error: "end_turn" }
|
||||||
|
|
||||||
@@ -306,6 +312,8 @@ export function permissionParams(sessionId: string, req: AskRequest, o: { patien
|
|||||||
...(o.patient ? { patient: true } : {}),
|
...(o.patient ? { patient: true } : {}),
|
||||||
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
|
// Which subagent asks (capability jobs), so the card can say — it may ask while the session itself is idle.
|
||||||
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
|
...(req.source ? { job: { id: req.source.jobId, agent: req.source.agent, description: req.source.description } } : {}),
|
||||||
|
// Spec 3.4.0 (capability tree): a subagent of the tree whose call only the person can allow.
|
||||||
|
...(req.source?.subagent ? { subagent: req.source.subagent } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -468,6 +476,10 @@ export class AcpAgent {
|
|||||||
peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
|
peer.handle("_lembas/job/stop", (p) => this.job("stop", p))
|
||||||
peer.handle("_lembas/job/send", (p) => this.job("send", p))
|
peer.handle("_lembas/job/send", (p) => this.job("send", p))
|
||||||
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
|
peer.handle("_lembas/job/transcript", (p) => this.job("transcript", p))
|
||||||
|
peer.handle("_lembas/team", (p) => this.tree("team", p))
|
||||||
|
peer.handle("_lembas/team/transcript", (p) => this.tree("team_transcript", p))
|
||||||
|
peer.handle("_lembas/team/findings", (p) => this.tree("team_findings", p))
|
||||||
|
peer.handle("_lembas/team/stop", (p) => this.tree("team_stop", p))
|
||||||
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
|
peer.on("_lembas/terminal/input", (p) => this.terminalInput(p))
|
||||||
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
peer.on("_lembas/terminal/resize", (p) => this.terminalResize(p))
|
||||||
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
peer.on("_lembas/terminal/close", (p) => this.terminalClose(String(p?.terminalId ?? "")))
|
||||||
@@ -675,6 +687,8 @@ export class AcpAgent {
|
|||||||
const effort = effortOf(p?.effort)
|
const effort = effortOf(p?.effort)
|
||||||
// A level the model does not offer is read as its start level; the answer says the one used.
|
// A level the model does not offer is read as its start level; the answer says the one used.
|
||||||
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
|
if (effort !== undefined) s.app.engine.effort = readLevel(s.app.engine.model, effort)
|
||||||
|
// Spec 3.4.0: the chat's Subagents setting, changed between turns.
|
||||||
|
if (p?.agents) this.takeAgents(s, { agents: p.agents })
|
||||||
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
|
return { model: s.app.engine.model.ref, effort: s.app.engine.effort }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -724,6 +738,19 @@ export class AcpAgent {
|
|||||||
private takeContext(s: Session, p: any) {
|
private takeContext(s: Session, p: any) {
|
||||||
const ctx = contextOf(p?._meta)
|
const ctx = contextOf(p?._meta)
|
||||||
if (ctx) s.app.setContext(ctx, this.instanceConnection)
|
if (ctx) s.app.setContext(ctx, this.instanceConnection)
|
||||||
|
this.takeAgents(s, p?._meta?.lembas)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (capability agents): the chat's Subagents setting, sent with every turn by the
|
||||||
|
* instance — absent (an older one, an editor on serve --stdio): the device's own, as before.
|
||||||
|
* `said`: the person's earlier words in the chat. */
|
||||||
|
private takeAgents(s: Session, m: any) {
|
||||||
|
if (!this.lembas) return
|
||||||
|
const team = s.app.team.team
|
||||||
|
const a = m?.agents
|
||||||
|
if (Number.isInteger(a?.min) && Number.isInteger(a?.max) && a.min >= 1 && a.max >= 1) team.setSetting(Math.min(a.min, a.max), a.max)
|
||||||
|
if (m?.subagents === false) team.subagents = false
|
||||||
|
if (Array.isArray(m?.said) && !s.app.engine.saidBefore.length) s.app.engine.saidBefore = m.said.filter((x: unknown): x is string => typeof x === "string")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The session's plan, as a `plan` update — after session/new and after opening from the
|
/** The session's plan, as a `plan` update — after session/new and after opening from the
|
||||||
@@ -1067,6 +1094,8 @@ export class AcpAgent {
|
|||||||
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
...(s.turns.last ? { lastTurnId: s.turns.last } : {}),
|
||||||
plan: s.app.engine.plan ?? null,
|
plan: s.app.engine.plan ?? null,
|
||||||
jobs: jobList(s.app),
|
jobs: jobList(s.app),
|
||||||
|
progress: s.app.engine.progress.current,
|
||||||
|
tree: treeOf(s.app),
|
||||||
}
|
}
|
||||||
const row = (this.store ??= new Store()).session(id)
|
const row = (this.store ??= new Store()).session(id)
|
||||||
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
|
// Not open: the last turn it finished, as kept across a restart, its plan and its jobs as stored.
|
||||||
@@ -1091,6 +1120,24 @@ export class AcpAgent {
|
|||||||
return { jobs: jobList(this.get(p).app) }
|
return { jobs: jobList(this.get(p).app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team`, `/transcript`, `/findings`, `/stop` (spec 3.4.0, capability tree): the
|
||||||
|
* session's subagent tree — the terminal's through the hub, as the jobs go. */
|
||||||
|
private async tree(op: "team" | "team_transcript" | "team_findings" | "team_stop", p: any) {
|
||||||
|
const t = this.terminalFor(p)
|
||||||
|
if (t) return t.client.request("hub/job", { sessionId: t.sessionId, op, id: p?.id, since: p?.since }, 15_000)
|
||||||
|
const app = this.get(p).app
|
||||||
|
switch (op) {
|
||||||
|
case "team":
|
||||||
|
return teamView(app, p?.since)
|
||||||
|
case "team_transcript":
|
||||||
|
return teamTranscript(app, p?.id)
|
||||||
|
case "team_findings":
|
||||||
|
return teamFindings(app, p?.id)
|
||||||
|
case "team_stop":
|
||||||
|
return teamStop(app, p?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
|
/** `_lembas/job/output`, `/stop`, `/send`, `/transcript` (acp/jobs.ts): one job of the session. */
|
||||||
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
|
private async job(op: "output" | "stop" | "send" | "transcript", p: any) {
|
||||||
const t = this.terminalFor(p)
|
const t = this.terminalFor(p)
|
||||||
@@ -1291,7 +1338,7 @@ export class AcpAgent {
|
|||||||
private forward(s: Session, e: Event) {
|
private forward(s: Session, e: Event) {
|
||||||
// A model change says whether the model is this link's instance's: its ref is the
|
// A model change says whether the model is this link's instance's: its ref is the
|
||||||
// provider's now (`deepseek/x`), so the ref alone no longer tells.
|
// provider's now (`deepseek/x`), so the ref alone no longer tells.
|
||||||
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e
|
const event = e.type === "model" ? { ...e, instance: this.isInstance(e.connection) } : e.type === "team" ? { ...e, tree: treeOf(s.app) } : e
|
||||||
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
|
if (this.lembas) this.peer.notify("_lembas/event", { sessionId: s.id, event })
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
case "text":
|
case "text":
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { App } from "../app.ts"
|
import type { App } from "../app.ts"
|
||||||
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
|
import { jobRegistry, type Job, type JobInfo } from "../jobs/registry.ts"
|
||||||
import type { Store } from "../session/store.ts"
|
import type { Store } from "../session/store.ts"
|
||||||
|
import type { TreeInfo } from "../team/team.ts"
|
||||||
import { historyTurns } from "./agent.ts"
|
import { historyTurns } from "./agent.ts"
|
||||||
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
|
import { INVALID_PARAMS, RpcError } from "./rpc.ts"
|
||||||
|
|
||||||
@@ -79,3 +80,48 @@ export function storedJobs(store: Store, sessionId: string): JobInfo[] {
|
|||||||
}
|
}
|
||||||
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
|
return [...list.filter((j) => j.state === "running"), ...list.filter((j) => j.state !== "running")]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Spec 3.4.0 (acp.md capability `tree`): the subagent tree of a session ────────────────────
|
||||||
|
|
||||||
|
/** The tree (subagents.md §12), or null for a session with no subagents. */
|
||||||
|
export function treeOf(app: App): TreeInfo | null {
|
||||||
|
const t = app.team.team
|
||||||
|
if (!t.offered && !t.hasMembers() && !t.ended.length) return null
|
||||||
|
return t.info()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team`: the tree and the whole room above `since`. */
|
||||||
|
export function teamView(app: App, since: unknown) {
|
||||||
|
const n = typeof since === "number" && since >= 0 ? since : 0
|
||||||
|
const t = app.team.team
|
||||||
|
return { tree: treeOf(app), room: t.room.filter((m) => m.n > n).map((m) => ({ n: m.n, from: m.from, to: m.to, recipients: m.recipients, text: m.text, time: m.time, report: m.report, kind: m.kind })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function subagentOf(app: App, id: unknown) {
|
||||||
|
const key = String(id ?? "")
|
||||||
|
const a = app.team.team.find(key)
|
||||||
|
if (!a || a.id !== key || a.id === "lead") throw new RpcError(INVALID_PARAMS, `this session has no subagent ${key}`)
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/transcript`: a subagent's session, read-only. */
|
||||||
|
export function teamTranscript(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
const job = a.jobId ? app.registry.get(app.jobsSessionId(), a.jobId) : undefined
|
||||||
|
const messages = job?.subagent?.engine.messages ?? (job?.childSessionId && app.store ? app.store.messages(job.childSessionId) : [])
|
||||||
|
return { id: a.id, state: a.state, turns: historyTurns(messages, app.engine.o.toolCtx.cwd) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/findings`. */
|
||||||
|
export function teamFindings(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
return { id: a.id, findings: a.findings.map((f) => ({ ...f })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_lembas/team/stop`: the person stops a live subagent and its subtree. */
|
||||||
|
export function teamStop(app: App, id: unknown) {
|
||||||
|
const a = subagentOf(app, id)
|
||||||
|
if (a.endState) throw new RpcError(INVALID_PARAMS, `${a.id} has already ended`)
|
||||||
|
const gone = app.team.personStop(a.name)
|
||||||
|
return { stopped: app.team.team.ended.slice(-gone.length).map((x) => x.id) }
|
||||||
|
}
|
||||||
+11
-2
@@ -16,7 +16,7 @@ import type { PlanReply, PlanRequest } from "../tool/plan_submit.ts"
|
|||||||
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
import type { QuestionReply, QuestionRequest } from "../tool/ask_user.ts"
|
||||||
import { readLevel } from "../provider/effort.ts"
|
import { readLevel } from "../provider/effort.ts"
|
||||||
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
|
import { clampMode, effortOf, markRemote, permissionParams, planParams, planReplyFrom, questionParams, questionReplyFrom, replyFrom, STOP, type PromptResult } from "./agent.ts"
|
||||||
import { jobList, jobOutput, jobSend, jobStop, jobTranscript } from "./jobs.ts"
|
import { jobList, jobOutput, jobSend, jobStop, jobTranscript, teamFindings, teamStop, teamTranscript, teamView, treeOf } from "./jobs.ts"
|
||||||
import type { SessionContext } from "../app.ts"
|
import type { SessionContext } from "../app.ts"
|
||||||
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
import { sessionTurnFile, sessionTurns, type TurnLog } from "./turns.ts"
|
||||||
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
import { dropAttachments, preparePrompt } from "./prompt.ts"
|
||||||
@@ -454,7 +454,7 @@ export class Share {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
|
if (this.sharedId && this.hub && this.app.engine.sessionId === this.sharedId) {
|
||||||
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e })
|
this.hub.notify("hub/event", { sessionId: this.sharedId, event: e.type === "team" ? { ...e, tree: treeOf(this.app) } : e })
|
||||||
if (e.type === "task" && e.state === "end") this.sendCommands()
|
if (e.type === "task" && e.state === "end") this.sendCommands()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,6 +520,15 @@ export class Share {
|
|||||||
return jobSend(this.app, p?.id, p?.text)
|
return jobSend(this.app, p?.id, p?.text)
|
||||||
case "transcript":
|
case "transcript":
|
||||||
return jobTranscript(this.app, p?.id)
|
return jobTranscript(this.app, p?.id)
|
||||||
|
// Spec 3.4.0 (capability tree): the subagent tree, through the hub as the jobs go.
|
||||||
|
case "team":
|
||||||
|
return teamView(this.app, p?.since)
|
||||||
|
case "team_transcript":
|
||||||
|
return teamTranscript(this.app, p?.id)
|
||||||
|
case "team_findings":
|
||||||
|
return teamFindings(this.app, p?.id)
|
||||||
|
case "team_stop":
|
||||||
|
return teamStop(this.app, p?.id)
|
||||||
default:
|
default:
|
||||||
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
|
throw new RpcError(INVALID_PARAMS, `no such job operation: ${String(p?.op ?? "")}`)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -546,7 +546,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
let job: Job
|
let job: Job
|
||||||
// The asker is the session's; a child's ask says which subagent it comes from.
|
// The asker is the session's; a child's ask says which subagent it comes from.
|
||||||
const askerFor = (): Asker => ({
|
const askerFor = (): Asker => ({
|
||||||
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description } }),
|
ask: (req) => o.asker.ask({ ...req, source: { jobId: job.id, agent: spec.name, description, ...(member ? { subagent: { id: member.id, name: member.name, position: member.role } } : {}) } }),
|
||||||
get question() {
|
get question() {
|
||||||
return o.asker.question
|
return o.asker.question
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -130,7 +130,7 @@ export interface Asker {
|
|||||||
purpose?: string
|
purpose?: string
|
||||||
/** Set when a subagent running as a background job asks, so the card can say which — it may
|
/** Set when a subagent running as a background job asks, so the card can say which — it may
|
||||||
* ask while the session itself is idle. */
|
* ask while the session itself is idle. */
|
||||||
source?: { jobId: string; agent: string; description: string }
|
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
|
||||||
}): Promise<AskReply>
|
}): Promise<AskReply>
|
||||||
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
|
/** The ask_user tool. Absent: the tool reports that nobody can be asked. `callId`: the tool call's
|
||||||
* id, which keys the card on another screen. */
|
* id, which keys the card on another screen. */
|
||||||
|
|||||||
@@ -647,9 +647,13 @@ export class Engine {
|
|||||||
* included, as the store keeps them; never a note, a marked message or a machine turn. */
|
* included, as the store keeps them; never a note, a marked message or a machine turn. */
|
||||||
personSaid(): string[] {
|
personSaid(): string[] {
|
||||||
const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages
|
const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages
|
||||||
return personTexts(all, this.o.steerMark ?? STEER_MARK)
|
return [...this.saidBefore, ...personTexts(all, this.o.steerMark ?? STEER_MARK)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Spec 3.4.0 (acp.md, *Subagents in an agent chat*): the person's earlier words in a chat this
|
||||||
|
* device has not seen, as the client sent them (`said` on session/new). */
|
||||||
|
saidBefore: string[] = []
|
||||||
|
|
||||||
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
|
/** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed.
|
||||||
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
|
* `meta`: the turn's id and what was attached, said on the `prompt` event. */
|
||||||
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@ export interface Pending {
|
|||||||
/** What the model says the call is for. */
|
/** What the model says the call is for. */
|
||||||
purpose?: string
|
purpose?: string
|
||||||
/** The subagent (running as a job) that asks, when one does. */
|
/** The subagent (running as a job) that asks, when one does. */
|
||||||
source?: { jobId: string; agent: string; description: string }
|
source?: { jobId: string; agent: string; description: string; /** Spec 3.4.0: a subagent of the tree. */ subagent?: { id: string; name: string; position: string } }
|
||||||
resolve: (r: AskReply) => void
|
resolve: (r: AskReply) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -268,7 +268,7 @@ test("the terminal: a real shell through the link, only where remote.terminal sa
|
|||||||
config(fake.url)
|
config(fake.url)
|
||||||
const root = project()
|
const root = project()
|
||||||
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
|
const off = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true })
|
||||||
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show"])
|
expect(off.init._meta.lembas.capabilities).toEqual(["directories", "configure", "steer", "sessions", "status", "delete", "compact", "title", "files", "commands", "attachments", "ask", "plan", "model", "turns", "context", "library", "prototypes", "plan_state", "rewind", "jobs", "show", "progress", "tree", "agents"])
|
||||||
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
|
await expect(off.c.request("_lembas/terminal/open", { cwd: root })).rejects.toThrow("remote.terminal is off")
|
||||||
|
|
||||||
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
|
const { c, init } = await client({ roots: [root], maxMode: "edit", approvalTimeoutMs: 50, requireTrust: true, terminal: true })
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Subagents v2 (harness spec 3.4.0, subagents.md §5–§14; progress.md) through the whole app: a
|
||||||
|
// subagent's team_finish ends its turn, a narrowed one is refused what only the main agent may
|
||||||
|
// allow and asks for it, a call nobody in the tree can allow reaches the person naming the
|
||||||
|
// subagent, a subagent whose context fills gets one final turn and ends, every engine says its
|
||||||
|
// progress as phase events, and an agent chat's tree goes over the link. The rules themselves are
|
||||||
|
// conformance/team.json's and delegation.json's.
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { createApp, type App } from "../src/app.ts"
|
||||||
|
import type { AskReply, Event } from "../src/bus/index.ts"
|
||||||
|
import { paths } from "../src/config/paths.ts"
|
||||||
|
import { LOOP } from "../src/harness.ts"
|
||||||
|
import { AcpAgent } from "../src/acp/agent.ts"
|
||||||
|
import { Peer, type Transport } from "../src/acp/rpc.ts"
|
||||||
|
import { setTrust } from "../src/project/root.ts"
|
||||||
|
import { delta, fakeProvider, toolCall, type Fake, type Scripted } from "./fake-provider.ts"
|
||||||
|
|
||||||
|
let fake: Fake | undefined
|
||||||
|
let app: App | undefined
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
fake?.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
const T = LOOP.team
|
||||||
|
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
|
||||||
|
const call = (id: string, name: string, args: unknown, usage?: { prompt_tokens: number; completion_tokens: number }): Scripted => ({
|
||||||
|
chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls"), ...(usage ? [{ choices: [], usage }] : [])],
|
||||||
|
})
|
||||||
|
const system = (body: any): string => body.messages[0].content
|
||||||
|
const memberName = (body: any): string | undefined => /You are "([a-z0-9-]+)" \(s\d+\), a subagent/.exec(system(body))?.[1]
|
||||||
|
const lastUser = (body: any): string => {
|
||||||
|
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
|
||||||
|
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
|
||||||
|
}
|
||||||
|
const toolResults = (body: any): string[] => body.messages.filter((m: any) => m.role === "tool").map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)).replace(/^\[#\d+\]\n/, ""))
|
||||||
|
|
||||||
|
function setup(answer: (body: any) => Scripted | undefined, spec = "{ context: 32768, parallel: 4 }") {
|
||||||
|
fake = fakeProvider(answer)
|
||||||
|
mkdirSync(paths.config, { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
|
||||||
|
writeFileSync(join(paths.config, "config.yaml"), "model: fake/m\n")
|
||||||
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-sub2-")))
|
||||||
|
Bun.spawnSync(["git", "init", "-q", dir])
|
||||||
|
setTrust(dir, "trusted")
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
async function until(what: () => boolean, ms = 10000) {
|
||||||
|
const end = Date.now() + ms
|
||||||
|
while (!what()) {
|
||||||
|
if (Date.now() > end) throw new Error("timed out")
|
||||||
|
await Bun.sleep(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The lead's script: the calls in order, then a closing line; each member's by its name. */
|
||||||
|
function scripted(lead: Scripted[], members: Record<string, Scripted[]>) {
|
||||||
|
const at: Record<string, number> = {}
|
||||||
|
return (body: any): Scripted => {
|
||||||
|
const who = memberName(body) ?? "lead"
|
||||||
|
const list = who === "lead" ? lead : (members[who] ?? [])
|
||||||
|
const n = (at[who] = (at[who] ?? -1) + 1)
|
||||||
|
return list[n] ?? say(`${who} is done.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("subagents v2 (spec 3.4.0)", () => {
|
||||||
|
test("team_finish reports to the parent and ends the subagent: nothing runs after that call", async () => {
|
||||||
|
const dir = setup(
|
||||||
|
scripted([call("a1", "team_add", { name: "api", role: "the API", task: "Write the API.", goal: "a service" }), say("api is on it.")], {
|
||||||
|
api: [call("f1", "team_finish", { report: "The API is done: src/api.ts." }), call("x1", "write", { path: "late.txt", content: "x" })],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) }, store: false })
|
||||||
|
await app.engine.prompt("build it")
|
||||||
|
await until(() => app!.team.team.ended.length === 1)
|
||||||
|
await until(() => !app!.engine.busy)
|
||||||
|
const t = app.team.team
|
||||||
|
expect(t.ended[0]).toMatchObject({ id: "s1", name: "api", state: "done" })
|
||||||
|
expect(t.room.find((m) => m.report)?.text).toBe("The API is done: src/api.ts.")
|
||||||
|
// Its one request: nothing after the team_finish call.
|
||||||
|
expect(fake!.requests.filter((r) => memberName(r) === "api")).toHaveLength(1)
|
||||||
|
expect(await Bun.file(join(dir, "late.txt")).exists()).toBe(false)
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
test("permissions: a narrowed subagent is told to ask the main agent; a call nobody in the tree can allow reaches the person, naming the subagent", async () => {
|
||||||
|
const asked: any[] = []
|
||||||
|
const dir = setup(
|
||||||
|
scripted(
|
||||||
|
[
|
||||||
|
call("a1", "team_add", { name: "docs", role: "the docs", task: "Write the docs.", goal: "docs", mode: "manual", request: ["write"] }),
|
||||||
|
call("a2", "team_add", { name: "ops", role: "the release", task: "Push it." }),
|
||||||
|
say("Both are on it."),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
docs: [call("w1", "write", { path: "README.md", content: "# Docs\n" }), say("asked")],
|
||||||
|
ops: [call("b1", "bash", { command: "git push origin main" }), say("pushed or not")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (req): Promise<AskReply> => (asked.push(req), { kind: "deny" }) }, store: false })
|
||||||
|
await app.engine.prompt("go")
|
||||||
|
await until(() => fake!.requests.filter((r) => memberName(r) === "docs").length >= 2 && fake!.requests.filter((r) => memberName(r) === "ops").length >= 2)
|
||||||
|
const docs = fake!.requests.filter((r) => memberName(r) === "docs")
|
||||||
|
expect(toolResults(docs[1])[0]).toBe(T.texts.requestable)
|
||||||
|
// The docs subagent has team_request (it has request options); the ops subagent does not.
|
||||||
|
expect(docs[0].tools.map((x: any) => x.function.name)).toContain("team_request")
|
||||||
|
const ops = fake!.requests.filter((r) => memberName(r) === "ops")
|
||||||
|
expect(ops[0].tools.map((x: any) => x.function.name)).not.toContain("team_request")
|
||||||
|
expect(ops[0].tools.map((x: any) => x.function.name)).not.toContain("team_add")
|
||||||
|
// An all-edit tree: the subagent's push reaches the person, not the main agent.
|
||||||
|
expect(asked.map((r) => [r.tool, r.source?.subagent?.name])).toEqual([["bash", "ops"]])
|
||||||
|
expect(asked[0].source.subagent).toEqual({ id: "s2", name: "ops", position: "the release" })
|
||||||
|
expect(await Bun.file(join(dir, "README.md")).exists()).toBe(false)
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
test("a subagent whose context fills is paused, gets one final turn, and ends; its parent gets the notice and its last reply", async () => {
|
||||||
|
const dir = setup(
|
||||||
|
scripted([call("a1", "team_add", { name: "scan", role: "scans the code", task: "Scan everything.", goal: "a map" }), say("scan is on it.")], {
|
||||||
|
scan: [
|
||||||
|
call("n1", "team_findings", { action: "write", text: "src/ has 12 modules." }, { prompt_tokens: 3700, completion_tokens: 20 }),
|
||||||
|
say("Saved my findings; the tests are left."),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
"{ context: 4096, parallel: 4 }",
|
||||||
|
)
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) }, store: false })
|
||||||
|
await app.engine.prompt("map it")
|
||||||
|
await until(() => app!.team.team.ended.length === 1)
|
||||||
|
const t = app.team.team
|
||||||
|
expect(t.ended[0]).toMatchObject({ name: "scan", state: "out_of_context" })
|
||||||
|
expect(t.ended[0]!.findings.map((f) => f.text)).toEqual(["src/ has 12 modules."])
|
||||||
|
const scans = fake!.requests.filter((r) => memberName(r) === "scan")
|
||||||
|
// The final turn: its user message is the notice, under its mark.
|
||||||
|
expect(lastUser(scans[1])).toContain(T.texts.context_full.replace("{{steps}}", String(T.context.final_steps)))
|
||||||
|
const report = t.room.find((m) => m.report)!
|
||||||
|
expect(report.text).toBe(`${T.texts.gone.replace("{{name}}", "scan").replace("{{id}}", "s1").replace("{{name}}", "scan")}\n\nSaved my findings; the tests are left.`)
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
test("every engine says its progress: reading the prompt, a tool, writing, and nothing once the turn is done", async () => {
|
||||||
|
const dir = setup(() => undefined)
|
||||||
|
let n = 0
|
||||||
|
fake!.stop()
|
||||||
|
fake = fakeProvider(() => (n++ === 0 ? call("l1", "list", {}) : say("Listed.")))
|
||||||
|
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: { context: 32768 }\n`, { mode: 0o600 })
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) }, store: false })
|
||||||
|
const phases: (string | null)[] = []
|
||||||
|
app.bus.on((e: Event) => e.type === "phase" && phases.push(e.progress?.phase ?? null))
|
||||||
|
await app.engine.prompt("list it")
|
||||||
|
expect(phases).toContain("reading_prompt")
|
||||||
|
expect(phases).toContain("running_tool")
|
||||||
|
expect(phases).toContain("writing")
|
||||||
|
expect(phases.at(-1)).toBeNull()
|
||||||
|
expect(app.engine.progress.current).toBeNull()
|
||||||
|
}, 20000)
|
||||||
|
})
|
||||||
|
|
||||||
|
function pair(): [Transport, Transport] {
|
||||||
|
const make = () => ({ msg: (_: string) => {}, end: () => {} })
|
||||||
|
const a = make()
|
||||||
|
const b = make()
|
||||||
|
const side = (me: typeof a, o: typeof a): Transport => ({
|
||||||
|
send: (t) => queueMicrotask(() => o.msg(t)),
|
||||||
|
onMessage: (fn) => (me.msg = fn),
|
||||||
|
onClose: (fn) => (me.end = fn),
|
||||||
|
close: () => (me.end(), o.end()),
|
||||||
|
})
|
||||||
|
return [side(a, b), side(b, a)]
|
||||||
|
}
|
||||||
|
|
||||||
|
test("an agent chat's tree over the link: the setting comes down with the turn; the tree, progress, findings and transcript come up", async () => {
|
||||||
|
const dir = setup(
|
||||||
|
scripted([call("a1", "team_add", { name: "api", role: "the API", task: "Write the API.", goal: "a service" }), say("api is on it.")], {
|
||||||
|
api: [call("n1", "team_findings", { action: "write", text: "Login is POST /session." }), say("API done.")],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const [ta, tb] = pair()
|
||||||
|
new AcpAgent(new Peer(ta))
|
||||||
|
const c = new Peer(tb)
|
||||||
|
const events: any[] = []
|
||||||
|
c.on("_lembas/event", (p) => void events.push(p.event))
|
||||||
|
c.handle("session/request_permission", () => ({ outcome: { outcome: "selected", optionId: "once" } }))
|
||||||
|
const init: any = await c.request("initialize", { protocolVersion: 1, clientCapabilities: { _meta: { lembas: { protocol: 3 } } } })
|
||||||
|
expect(init._meta.lembas.capabilities).toEqual(expect.arrayContaining(["progress", "tree", "agents"]))
|
||||||
|
const s: any = await c.request("session/new", { cwd: dir, mcpServers: [], _meta: { lembas: { agents: { min: 1, max: 3 }, said: ["Use the main model only."] } } })
|
||||||
|
await c.request("session/prompt", { sessionId: s.sessionId, prompt: [{ type: "text", text: "build it" }], _meta: { lembas: { agents: { min: 1, max: 3 } } } })
|
||||||
|
await until(() => events.some((e) => e.type === "team" && e.tree?.agents.some((a: any) => a.name === "api" && a.state === "finished")))
|
||||||
|
const view: any = await c.request("_lembas/team", { sessionId: s.sessionId })
|
||||||
|
expect(view.tree).toMatchObject({ min: 1, max: 3, size: 2 })
|
||||||
|
expect(view.tree.agents.map((a: any) => [a.id, a.name, a.parent])).toEqual([
|
||||||
|
["lead", "lead", null],
|
||||||
|
["s1", "api", "lead"],
|
||||||
|
])
|
||||||
|
expect(view.room.map((m: any) => [m.from, m.kind])).toEqual([["api", "report"]])
|
||||||
|
expect(await c.request("_lembas/team/findings", { sessionId: s.sessionId, id: "s1" })).toMatchObject({ id: "s1", findings: [{ n: 1, text: "Login is POST /session." }] })
|
||||||
|
const tr: any = await c.request("_lembas/team/transcript", { sessionId: s.sessionId, id: "s1" })
|
||||||
|
expect(tr.turns[0]).toMatchObject({ role: "user" })
|
||||||
|
expect(JSON.stringify(tr.turns)).toContain("Write the API.")
|
||||||
|
expect(events.some((e) => e.type === "phase")).toBe(true)
|
||||||
|
const st: any = await c.request("_lembas/session/status", { sessionId: s.sessionId })
|
||||||
|
expect(st.tree.agents).toHaveLength(2)
|
||||||
|
expect("progress" in st).toBe(true)
|
||||||
|
}, 20000)
|
||||||
Reference in new issue
Block a user