@intentic/sandbox-contract 1.314.0 → 1.315.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/contracts/accounts.contract.d.ts +6 -0
- package/dist/contracts/accounts.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +1 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +1 -1
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +293 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +11 -1
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/system.contract.d.ts +3 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +1 -1
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/translator.contract.d.ts +12 -0
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/agent-events.js.map +1 -1
- package/dist/events/agent-words.d.ts.map +1 -1
- package/dist/events/agent-words.js +5 -4
- package/dist/events/agent-words.js.map +1 -1
- package/dist/events/requests.js +1 -1
- package/dist/events/requests.js.map +1 -1
- package/dist/events/system-events.d.ts +2 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/transcript.d.ts +1 -0
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/events/transcript.js +5 -1
- package/dist/events/transcript.js.map +1 -1
- package/dist/index.d.ts +315 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/models/plan-pools.d.ts +1 -0
- package/dist/models/plan-pools.d.ts.map +1 -1
- package/dist/models/plan-pools.js +3 -0
- package/dist/models/plan-pools.js.map +1 -1
- package/dist/schemas/agents.d.ts +7 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +12 -1
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +1 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/personas.js +1 -1
- package/dist/schemas/personas.js.map +1 -1
- package/dist/schemas/providers/plan-limits.d.ts +19 -0
- package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
- package/dist/schemas/providers/plan-limits.js +9 -3
- package/dist/schemas/providers/plan-limits.js.map +1 -1
- package/dist/schemas/providers/provider-oauth.d.ts +6 -0
- package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/terminal.d.ts.map +1 -1
- package/dist/schemas/terminal.js +3 -3
- package/dist/schemas/terminal.js.map +1 -1
- package/dist/text/transcript-fold.d.ts +8 -0
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +67 -14
- package/dist/text/transcript-fold.js.map +1 -1
- package/package.json +5 -5
- package/src/contracts/agent.contract.ts +1 -1
- package/src/contracts/agents.contract.ts +12 -0
- package/src/contracts/system.contract.ts +1 -1
- package/src/events/agent-events.ts +4 -2
- package/src/events/agent-words.test.ts +8 -1
- package/src/events/agent-words.ts +8 -5
- package/src/events/requests.ts +1 -1
- package/src/events/transcript.ts +9 -3
- package/src/models/plan-pools.test.ts +7 -0
- package/src/models/plan-pools.ts +6 -1
- package/src/schemas/agents.ts +16 -1
- package/src/schemas/personas.ts +1 -1
- package/src/schemas/providers/plan-limits.ts +13 -3
- package/src/schemas/terminal.ts +12 -10
- package/src/text/transcript-fold.test.ts +107 -0
- package/src/text/transcript-fold.ts +95 -16
package/src/models/plan-pools.ts
CHANGED
|
@@ -145,7 +145,7 @@ export interface ServiceFacts {
|
|
|
145
145
|
// Why the sign-in needs renewing, where the provider said.
|
|
146
146
|
readonly detail?: string | undefined;
|
|
147
147
|
readonly seatRefusal?: string | undefined;
|
|
148
|
-
readonly cooling?: { readonly until?: number | undefined; readonly reason?: string | undefined } | undefined;
|
|
148
|
+
readonly cooling?: { readonly until?: number | undefined; readonly reason?: string | undefined; readonly verify?: string | undefined } | undefined;
|
|
149
149
|
readonly usage?: AccountUsage | undefined;
|
|
150
150
|
}
|
|
151
151
|
|
|
@@ -240,6 +240,11 @@ export const serviceState = (facts: ServiceFacts, refusal?: ProviderRefusal, mod
|
|
|
240
240
|
return { kind: "blocked", fix: "admin", reason: refusal.message };
|
|
241
241
|
}
|
|
242
242
|
const cooling = facts.cooling;
|
|
243
|
+
// A bench the provider wants a person for: the account's owner confirms it on the provider's page, and no wait or
|
|
244
|
+
// reconnect here lifts it. Outranks the retry instant, which is only when the proxy will ask again.
|
|
245
|
+
if (cooling?.verify !== undefined) {
|
|
246
|
+
return { kind: "blocked", fix: "verify", reason: cooling.reason ?? "the provider wants this account verified", url: cooling.verify };
|
|
247
|
+
}
|
|
243
248
|
// A bench with no instant is one no wait lifts (a Google account with no project): somebody has to connect again.
|
|
244
249
|
if (cooling !== undefined && cooling.until === undefined) {
|
|
245
250
|
return { kind: "blocked", fix: "reconnect", reason: cooling.reason ?? "benched by the translator" };
|
package/src/schemas/agents.ts
CHANGED
|
@@ -418,6 +418,13 @@ export const AgentSummarySchema = z.object({
|
|
|
418
418
|
.describe(
|
|
419
419
|
"When somebody last opened it, in milliseconds. Newer activity than this is what makes it unread. Kept by the sandbox rather than by a browser, so clearing site data or picking up a phone does not resurrect every badge.",
|
|
420
420
|
),
|
|
421
|
+
// Browser-side words the daemon cannot read, reported so the unattended archive sweep never files them away.
|
|
422
|
+
unsentAt: z
|
|
423
|
+
.number()
|
|
424
|
+
.optional()
|
|
425
|
+
.describe(
|
|
426
|
+
"Since when somebody's composer has held a message for it that they have not sent yet, in milliseconds. While set, the sandbox never archives it on its own for being idle.",
|
|
427
|
+
),
|
|
421
428
|
attention: AgentAttentionSchema.describe("Which kinds of waiting-for-you it is doing."),
|
|
422
429
|
// Only the causes that still hold, re-read live (conversations/land/standing.ts), never the stored report's own list: a
|
|
423
430
|
// blocker the user has since cleared is not something to offer them an action about.
|
|
@@ -448,7 +455,7 @@ export const AgentSummarySchema = z.object({
|
|
|
448
455
|
})
|
|
449
456
|
.optional()
|
|
450
457
|
.describe(
|
|
451
|
-
"Subagents
|
|
458
|
+
"Subagents this one started, in-process and spawned alike. Absent means it never has, which is most conversations. Their spend is their own and is not folded into this conversation's cost.",
|
|
452
459
|
),
|
|
453
460
|
// Cumulative output across every repo (base → branch tip), refreshed on each land; independent of what has actually
|
|
454
461
|
// landed.
|
|
@@ -740,6 +747,14 @@ export const AgentAutoLandSchema = z.object({
|
|
|
740
747
|
"Whether its work merges automatically when a turn finishes. Null clears the override and goes back to following the sandbox-wide setting, so a conversation does not sit holding a frozen copy of a default it has quietly stopped following.",
|
|
741
748
|
),
|
|
742
749
|
});
|
|
750
|
+
// A composer's unsent words, reported by the editor that holds them: the words themselves stay in the browser.
|
|
751
|
+
export const AgentUnsentSchema = z.object({
|
|
752
|
+
id: z.string().min(1).describe("Which conversation."),
|
|
753
|
+
at: z
|
|
754
|
+
.number()
|
|
755
|
+
.nullable()
|
|
756
|
+
.describe("When the composer started holding the unsent message, in milliseconds. Null says it no longer holds one: it was sent or cleared."),
|
|
757
|
+
});
|
|
743
758
|
// Same `null`-clears-the-override shape as autoLand, for this conversation's own answer to one ending's question.
|
|
744
759
|
// One route rather than one per ending: they are the same decision asked about different walls, and three near-identical
|
|
745
760
|
// verbs is how the surfaces drifted apart in the first place.
|
package/src/schemas/personas.ts
CHANGED
|
@@ -134,7 +134,7 @@ export const TURN_BRIEFING_NOTES: readonly TurnBriefingNote[] = [
|
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
136
|
id: "delegation",
|
|
137
|
-
label: "Spawning
|
|
137
|
+
label: "Spawning subagents",
|
|
138
138
|
when: "First message, on a shell-only runtime, for a card that may delegate.",
|
|
139
139
|
cost: "The agent does the work itself rather than handing parts of it to other conversations.",
|
|
140
140
|
},
|
|
@@ -47,7 +47,7 @@ export const AccountUsageSchema = z.object({
|
|
|
47
47
|
export type AccountUsage = z.infer<typeof AccountUsageSchema>;
|
|
48
48
|
// Who has to act for a blocked account, which is the only thing a surface chooses its instruction by: the reason is the
|
|
49
49
|
// words for a person, never a key.
|
|
50
|
-
export const AccountFixSchema = z.enum(["reconnect", "admin", "wait"]);
|
|
50
|
+
export const AccountFixSchema = z.enum(["reconnect", "admin", "verify", "wait"]);
|
|
51
51
|
export type AccountFix = z.infer<typeof AccountFixSchema>;
|
|
52
52
|
// Whether an account can serve a turn, decided once by the daemon (models/plan-pools.ts `serviceState`) from every fact
|
|
53
53
|
// it holds about it: a revoked sign-in, a lost seat, a translator bench, a refusal still standing, and its plan limits.
|
|
@@ -69,10 +69,11 @@ export const AccountStateSchema = z.discriminatedUnion("kind", [
|
|
|
69
69
|
z.object({
|
|
70
70
|
kind: z.literal("blocked"),
|
|
71
71
|
fix: AccountFixSchema.describe(
|
|
72
|
-
"Who can make it serve again: `reconnect` (sign in again on this sandbox), `admin` (an organisation admin hands the seat back), or `wait` (it lifts by itself, at `until` where known).",
|
|
72
|
+
"Who can make it serve again: `reconnect` (sign in again on this sandbox), `admin` (an organisation admin hands the seat back), `verify` (the account's owner confirms it on the provider's page, at `url`), or `wait` (it lifts by itself, at `until` where known).",
|
|
73
73
|
),
|
|
74
74
|
reason: z.string().describe("Why, in words a person can act on: the provider's own sentence where it gave one."),
|
|
75
75
|
until: z.number().optional().describe("When waiting lifts it, in epoch seconds, for `wait` only."),
|
|
76
|
+
url: z.string().optional().describe("The provider's page where the account's owner lifts it, for `verify` only."),
|
|
76
77
|
}),
|
|
77
78
|
z.object({ kind: z.literal("unknown").describe("Nothing blocks it and nothing has been measured: usable, never read as room.") }),
|
|
78
79
|
]);
|
|
@@ -151,6 +152,9 @@ export const TranslatorAccountSchema = z.object({
|
|
|
151
152
|
// Why, in the words of what is missing: the proxy's own sentence where it gave one short enough to print,
|
|
152
153
|
// else this sandbox's. Never a pasted upstream body.
|
|
153
154
|
reason: z.string().optional(),
|
|
155
|
+
// The provider's page where the account's owner confirms it (Google's VALIDATION_REQUIRED). Present means
|
|
156
|
+
// a person has to act there: the proxy's retry fails until they do.
|
|
157
|
+
verify: z.string().optional(),
|
|
154
158
|
})
|
|
155
159
|
.optional(),
|
|
156
160
|
state: AccountStateSchema.optional().describe(
|
|
@@ -205,7 +209,7 @@ export const AgentReplySchema = z.discriminatedUnion("kind", [
|
|
|
205
209
|
feedback: z.string().optional().describe("Why not, which goes back to the model as the reason."),
|
|
206
210
|
// Whole, not a patch: effort or an account named for one model means nothing on another.
|
|
207
211
|
child: ChildRunSchema.optional().describe(
|
|
208
|
-
"For a request to start a
|
|
212
|
+
"For a request to start a subagent: what to start it on instead of what the agent asked for. It replaces the whole run (model, account, effort and the rest), not only the fields it names. Ignored with a no, and on any other request.",
|
|
209
213
|
),
|
|
210
214
|
}),
|
|
211
215
|
z.object({
|
|
@@ -400,6 +404,12 @@ export const SwitchAccountSchema = z.object({
|
|
|
400
404
|
.describe(
|
|
401
405
|
"Keep the provider session across the move (the model keeps everything, and re-reads all of it once on the other account) rather than opening a fresh one seeded from the record.",
|
|
402
406
|
),
|
|
407
|
+
run: z
|
|
408
|
+
.boolean()
|
|
409
|
+
.optional()
|
|
410
|
+
.describe(
|
|
411
|
+
"Also run a turn that a spent allowance, a stop or a refusal is holding, at once on the new account. Leave it out to only move the conversation: a held turn stays held until something asks for it.",
|
|
412
|
+
),
|
|
403
413
|
});
|
|
404
414
|
export type SwitchAccount = z.infer<typeof SwitchAccountSchema>;
|
|
405
415
|
export const AccountSwitchedSchema = z.object({
|
package/src/schemas/terminal.ts
CHANGED
|
@@ -128,13 +128,15 @@ export const BrowsersListSchema = z.object({
|
|
|
128
128
|
});
|
|
129
129
|
export type BrowsersList = z.infer<typeof BrowsersListSchema>;
|
|
130
130
|
export const BrowserNameParamSchema = z.object({ name: z.string().describe("Which browser.") });
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// subagent: the
|
|
134
|
-
//
|
|
135
|
-
// daemon
|
|
136
|
-
//
|
|
137
|
-
//
|
|
131
|
+
// A subagent is any agent another agent started, and every surface shows one the same way whichever mechanism started
|
|
132
|
+
// it. The kind names the mechanism, never a second sort of thing:
|
|
133
|
+
// subagent: in-process, the runtime's own Agent/Task tool, tracked via SubagentStart/Stop hooks and task_* messages
|
|
134
|
+
// joined on toolUseId; it works in its parent's turn and tree, on its parent's provider.
|
|
135
|
+
// spawned: a full agent on any provider, started through the daemon's spawn door (agent/subagents/children.ts) as a
|
|
136
|
+
// conversation of its own, in its own worktree; the daemon reports its life directly, and it can outlive its parent's
|
|
137
|
+
// turn, be steered, and land its own work.
|
|
138
|
+
// id is the spawning tool call's id for an in-process one, its own conversation id for a spawned one. The kind changes
|
|
139
|
+
// only where its record is read from and what else can be done with it, never how it looks.
|
|
138
140
|
export const SubagentKindSchema = z.enum(["subagent", "spawned"]);
|
|
139
141
|
export type SubagentKind = z.infer<typeof SubagentKindSchema>;
|
|
140
142
|
// running/pending/blocked are live, the rest terminal. Uses the SDK's own task vocabulary rather than AgentStatus.
|
|
@@ -168,7 +170,7 @@ export const SubagentSessionSchema = z.object({
|
|
|
168
170
|
"The id of the tool call that started it (an SDK child) or the child's own conversation id (a spawned one); either way both sides already hold it, so a card links to its subagent with the id it has and the subagent points back the same way.",
|
|
169
171
|
),
|
|
170
172
|
kind: SubagentKindSchema.describe(
|
|
171
|
-
"
|
|
173
|
+
"How it was started: in-process by the runtime's own Agent/Task tool, or spawned by the daemon as a conversation of its own, on any provider. It changes where its record is read from and what else can be done with it, never what it is.",
|
|
172
174
|
),
|
|
173
175
|
// The conversation whose turn started it; how a card links back to the chat it belongs to.
|
|
174
176
|
conversationId: z.string().describe("The conversation whose turn started it, and the way back to the chat it belongs to."),
|
|
@@ -177,7 +179,7 @@ export const SubagentSessionSchema = z.object({
|
|
|
177
179
|
description: z.string().optional().describe("What it was asked to do, in one line."),
|
|
178
180
|
model: z.string().optional().describe("Which model it runs on."),
|
|
179
181
|
// Which provider serves a spawned child; an SDK subagent implies its own (its parent's).
|
|
180
|
-
provider: z.string().optional().describe("Which provider serves it, for a
|
|
182
|
+
provider: z.string().optional().describe("Which provider serves it, for a subagent spawned across providers."),
|
|
181
183
|
// How deep in the spawn tree; 1 means the turn itself started it. A subagent can itself delegate further.
|
|
182
184
|
spawnDepth: z
|
|
183
185
|
.number()
|
|
@@ -216,7 +218,7 @@ export const SubagentSessionSchema = z.object({
|
|
|
216
218
|
});
|
|
217
219
|
export type SubagentSession = z.infer<typeof SubagentSessionSchema>;
|
|
218
220
|
export const SubagentsListSchema = z.object({
|
|
219
|
-
sessions: z.array(SubagentSessionSchema).describe("Every subagent
|
|
221
|
+
sessions: z.array(SubagentSessionSchema).describe("Every subagent this sandbox's conversations have started, in-process and spawned alike."),
|
|
220
222
|
});
|
|
221
223
|
export type SubagentsList = z.infer<typeof SubagentsListSchema>;
|
|
222
224
|
export const SubagentIdParamSchema = z.object({ id: z.string() });
|
|
@@ -108,6 +108,113 @@ describe("foldTurn", () => {
|
|
|
108
108
|
]);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
// A spawned subagent is named by its own conversation, not the call that started it; the spawn answers with that
|
|
112
|
+
// name, which is how its card is found, so it reads on its card exactly as an in-process one does.
|
|
113
|
+
it("puts a spawned subagent on the card of the call whose result names it", () => {
|
|
114
|
+
const events: AgentEvent[] = [
|
|
115
|
+
{ kind: "tool_call", id: "call-9", name: "mcp__subagents__spawn", category: "other", status: "in_progress" },
|
|
116
|
+
{ kind: "subagent", id: "sub-brave-otter", subagentKind: "spawned", agentType: "Codex", description: "Port the parser", provider: "codex", background: true },
|
|
117
|
+
{ kind: "subagent_update", id: "sub-brave-otter", status: "pending", summary: "Waiting for memory." },
|
|
118
|
+
{ kind: "tool_call_update", id: "call-9", status: "completed", content: [{ type: "text", text: '{"ok":true,"child":"sub-brave-otter"}' }] },
|
|
119
|
+
{ kind: "subagent_update", id: "sub-brave-otter", status: "running", toolUses: 3, lastTool: "Edit" },
|
|
120
|
+
];
|
|
121
|
+
expect(foldOf("fan out", events).at(-1)?.tools).toEqual([
|
|
122
|
+
{
|
|
123
|
+
id: "call-9",
|
|
124
|
+
name: "mcp__subagents__spawn",
|
|
125
|
+
category: "other",
|
|
126
|
+
status: "completed",
|
|
127
|
+
content: [{ type: "text", text: '{"ok":true,"child":"sub-brave-otter"}' }],
|
|
128
|
+
subagent: {
|
|
129
|
+
id: "sub-brave-otter",
|
|
130
|
+
kind: "spawned",
|
|
131
|
+
agentType: "Codex",
|
|
132
|
+
description: "Port the parser",
|
|
133
|
+
provider: "codex",
|
|
134
|
+
background: true,
|
|
135
|
+
status: "running",
|
|
136
|
+
summary: "Waiting for memory.",
|
|
137
|
+
toolUses: 3,
|
|
138
|
+
lastTool: "Edit",
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// The shell door prints the new id on its first line, so a runtime with no spawn tool of its own gets the same card.
|
|
145
|
+
it("puts a subagent spawned from the shell on its command's card", () => {
|
|
146
|
+
const events: AgentEvent[] = [
|
|
147
|
+
{ kind: "subagent", id: "sub-quiet-fox", subagentKind: "spawned", agentType: "Grok", description: "Draft the docs" },
|
|
148
|
+
{ kind: "tool_call", id: "b1", name: "Bash", category: "execute", status: "in_progress", target: "agents spawn --provider grok --model grok-4 'Draft the docs'" },
|
|
149
|
+
{ kind: "tool_call_update", id: "b1", status: "completed", content: [{ type: "text", text: "sub-quiet-fox\nStarted." }] },
|
|
150
|
+
{ kind: "subagent_update", id: "sub-quiet-fox", status: "completed", summary: "Drafted." },
|
|
151
|
+
];
|
|
152
|
+
expect(foldOf("fan out", events).at(-1)?.tools?.[0]?.subagent).toEqual({
|
|
153
|
+
id: "sub-quiet-fox",
|
|
154
|
+
kind: "spawned",
|
|
155
|
+
agentType: "Grok",
|
|
156
|
+
description: "Draft the docs",
|
|
157
|
+
status: "completed",
|
|
158
|
+
summary: "Drafted.",
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Parallel spawns answer in any order; each card takes the one its own result names, and a card holds one subagent.
|
|
163
|
+
it("keeps each of several spawned subagents on its own card", () => {
|
|
164
|
+
const events: AgentEvent[] = [
|
|
165
|
+
{ kind: "tool_call", id: "s1", name: "mcp__subagents__spawn", category: "other", status: "in_progress" },
|
|
166
|
+
{ kind: "tool_call", id: "s2", name: "mcp__subagents__spawn", category: "other", status: "in_progress" },
|
|
167
|
+
{ kind: "subagent", id: "sub-a", subagentKind: "spawned", description: "A" },
|
|
168
|
+
{ kind: "subagent", id: "sub-b", subagentKind: "spawned", description: "B" },
|
|
169
|
+
{ kind: "tool_call_update", id: "s2", status: "completed", content: [{ type: "text", text: '{"ok":true,"child":"sub-b"}' }] },
|
|
170
|
+
{ kind: "tool_call_update", id: "s1", status: "completed", content: [{ type: "text", text: '{"ok":true,"child":"sub-a"}' }] },
|
|
171
|
+
{ kind: "tool_call", id: "w1", name: "mcp__subagents__wait", category: "other", status: "in_progress" },
|
|
172
|
+
{ kind: "tool_call_update", id: "w1", status: "completed", content: [{ type: "text", text: '{"agent":{"id":"sub-a"},"also":"sub-b"}' }] },
|
|
173
|
+
];
|
|
174
|
+
const tools = foldOf("fan out", events).at(-1)?.tools ?? [];
|
|
175
|
+
expect(tools.map((tool) => [tool.id, tool.subagent?.id, tool.subagent?.description])).toEqual([
|
|
176
|
+
["s1", "sub-a", "A"],
|
|
177
|
+
["s2", "sub-b", "B"],
|
|
178
|
+
["w1", undefined, undefined],
|
|
179
|
+
]);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// An in-process subagent heard a beat before its call still lands on that call, under the call's own id.
|
|
183
|
+
it("holds a subagent heard before its card until the card appears", () => {
|
|
184
|
+
const events: AgentEvent[] = [
|
|
185
|
+
{ kind: "subagent", id: "task-2", subagentKind: "subagent", agentType: "Explore" },
|
|
186
|
+
{ kind: "tool_call", id: "task-2", name: "Agent", category: "other", status: "in_progress" },
|
|
187
|
+
];
|
|
188
|
+
expect(foldOf("delegate", events).at(-1)?.tools?.[0]?.subagent).toEqual({ kind: "subagent", agentType: "Explore", status: "running" });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Live, the card's claim goes out as the patch of the result that made it, so a watching window draws the same card.
|
|
192
|
+
it("sends a spawned subagent's placement as the claiming card's own patch", () => {
|
|
193
|
+
const fold = new TranscriptFold(openingOf("fan out"));
|
|
194
|
+
fold.apply({ kind: "tool_call", id: "call-9", name: "mcp__subagents__spawn", category: "other", status: "in_progress" });
|
|
195
|
+
expect(fold.apply({ kind: "subagent", id: "sub-x", subagentKind: "spawned", description: "Port" })).toEqual([]);
|
|
196
|
+
const [patch] = fold.apply({ kind: "tool_call_update", id: "call-9", status: "completed", content: [{ type: "text", text: "sub-x" }] });
|
|
197
|
+
expect(patch).toEqual({
|
|
198
|
+
op: "tool",
|
|
199
|
+
index: 1,
|
|
200
|
+
tool: {
|
|
201
|
+
id: "call-9",
|
|
202
|
+
name: "mcp__subagents__spawn",
|
|
203
|
+
category: "other",
|
|
204
|
+
status: "completed",
|
|
205
|
+
content: [{ type: "text", text: "sub-x" }],
|
|
206
|
+
subagent: { id: "sub-x", kind: "spawned", description: "Port", status: "running" },
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
expect(fold.apply({ kind: "subagent_update", id: "sub-x", status: "blocked", summary: "Which port?" })).toEqual([
|
|
210
|
+
{
|
|
211
|
+
op: "tool",
|
|
212
|
+
index: 1,
|
|
213
|
+
tool: expect.objectContaining({ subagent: { id: "sub-x", kind: "spawned", description: "Port", status: "blocked", summary: "Which port?" } }),
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
});
|
|
217
|
+
|
|
111
218
|
it("drops a subagent's frames when the card that spawned them is absent", () => {
|
|
112
219
|
const events: AgentEvent[] = [
|
|
113
220
|
{ kind: "delta", text: "delegating" },
|
|
@@ -20,6 +20,11 @@ import { unspokenPromptRow } from "../events/agent-words.js";
|
|
|
20
20
|
// Folds a turn's frames into rows once, live and for the settled record alike, so a reopened chat matches what was on
|
|
21
21
|
// screen. `tag` selects the stream read: undefined is the main turn, a tool-call id is the subagent it spawned; other
|
|
22
22
|
// frames nest under the card that spawned them. Rows mutate in place; every patch carries a copy of what it names.
|
|
23
|
+
//
|
|
24
|
+
// Every subagent lands on the card of the call that started it, whichever mechanism started it. An in-process one (the
|
|
25
|
+
// runtime's own Agent/Task tool) is named by that call's id, so its card is found at once. A spawned one is named by its
|
|
26
|
+
// own conversation id, and every door that spawns one answers with that id (the spawn tool's `child`, the `agents
|
|
27
|
+
// spawn` CLI's first line), so its frames wait until a card's result names it, then ride that card from there on.
|
|
23
28
|
|
|
24
29
|
export type TurnEnding = "settled" | "stopped";
|
|
25
30
|
|
|
@@ -40,6 +45,13 @@ const defined = <T extends object>(value: T): Partial<T> =>
|
|
|
40
45
|
// A card's own fields: what a `tool` patch carries, so a delegation's growing subtree never rides one.
|
|
41
46
|
const ownFields = ({ children: _children, thinking: _thinking, ...own }: TranscriptTool): TranscriptTool => own;
|
|
42
47
|
|
|
48
|
+
// Everything a card's result said, as one string: what a subagent waiting for its card is looked for in.
|
|
49
|
+
const resultWords = (tool: TranscriptTool): string =>
|
|
50
|
+
(tool.content ?? [])
|
|
51
|
+
.filter((entry) => entry.type === "text")
|
|
52
|
+
.map((entry) => entry.text)
|
|
53
|
+
.join("\n");
|
|
54
|
+
|
|
43
55
|
const cardOf = (event: Extract<AgentEvent, { kind: "tool_call" }>): TranscriptTool => ({
|
|
44
56
|
id: event.id,
|
|
45
57
|
name: event.name,
|
|
@@ -212,6 +224,11 @@ export class TranscriptFold {
|
|
|
212
224
|
// Index of the open assistant bubble; always the last row, since every other row kind closes it first.
|
|
213
225
|
private bubble: number | undefined;
|
|
214
226
|
private readonly cards = new Map<string, CardPlace>();
|
|
227
|
+
// A subagent's own id to the card of the call that started it, once that card is known.
|
|
228
|
+
private readonly placed = new Map<string, string>();
|
|
229
|
+
// A subagent heard before its card: born, and perhaps already moving, while the call that started it had not yet
|
|
230
|
+
// said its id. Placed as soon as a card claims it; dropped with the fold if none ever does.
|
|
231
|
+
private readonly unplaced = new Map<string, TranscriptSubagent>();
|
|
215
232
|
// requestId to the row holding its card, for frames landing on it later (a reply, a late sentence, a receipt).
|
|
216
233
|
private readonly parked = new Map<string, number>();
|
|
217
234
|
// The turn's opening user row, where the checkpoint and daemon notes land; cleared once `retract` takes it back.
|
|
@@ -266,6 +283,7 @@ export class TranscriptFold {
|
|
|
266
283
|
const row = this.rows[index]!;
|
|
267
284
|
row.tools = [...(row.tools ?? []), tool];
|
|
268
285
|
this.cards.set(tool.id, { tool, row: index });
|
|
286
|
+
this.claimById(tool);
|
|
269
287
|
return [...opened, { op: "tool", index, tool: structuredClone(tool) }];
|
|
270
288
|
}
|
|
271
289
|
case "tool_call_update":
|
|
@@ -276,27 +294,16 @@ export class TranscriptFold {
|
|
|
276
294
|
}
|
|
277
295
|
if (event.content !== undefined) {
|
|
278
296
|
tool.content = event.content;
|
|
297
|
+
this.claimByResult(tool);
|
|
279
298
|
}
|
|
280
299
|
if (event.locations !== undefined) {
|
|
281
300
|
tool.locations = event.locations;
|
|
282
301
|
}
|
|
283
302
|
});
|
|
284
|
-
case "subagent":
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
return this.
|
|
288
|
-
tool.subagent = { ...rest, kind: subagentKind, status: "running" };
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
case "subagent_update": {
|
|
292
|
-
// Present fields replace, absent ones leave the child alone, as in tool_call_update.
|
|
293
|
-
const { kind: _kind, id, ...patch } = event;
|
|
294
|
-
return this.patchCard(id, (tool) => {
|
|
295
|
-
if (tool.subagent !== undefined) {
|
|
296
|
-
tool.subagent = { ...tool.subagent, ...(defined(patch) as Partial<TranscriptSubagent>) };
|
|
297
|
-
}
|
|
298
|
-
});
|
|
299
|
-
}
|
|
303
|
+
case "subagent":
|
|
304
|
+
return this.subagentBorn(event);
|
|
305
|
+
case "subagent_update":
|
|
306
|
+
return this.subagentMoved(event);
|
|
300
307
|
case "todos": {
|
|
301
308
|
const [index, opened] = this.open();
|
|
302
309
|
this.rows[index]!.todos = [...event.items];
|
|
@@ -561,11 +568,83 @@ export class TranscriptFold {
|
|
|
561
568
|
const child = cardOf(event);
|
|
562
569
|
place.tool.children = [...(place.tool.children ?? []), child];
|
|
563
570
|
this.cards.set(child.id, { tool: child, row: place.row, parent });
|
|
571
|
+
this.claimById(child);
|
|
564
572
|
return [{ op: "tool", index: place.row, tool: structuredClone(child), parent }];
|
|
565
573
|
}
|
|
566
574
|
return [];
|
|
567
575
|
}
|
|
568
576
|
|
|
577
|
+
// A subagent starts: on its card when that is known, else held until a card claims it. A later birth under the same
|
|
578
|
+
// id (a follow-up turn of the same subagent) starts its card's state over.
|
|
579
|
+
private subagentBorn(event: Extract<AgentEvent, { kind: "subagent" }>): TranscriptPatch[] {
|
|
580
|
+
const { kind: _kind, id, subagentKind, ...rest } = event;
|
|
581
|
+
const born: TranscriptSubagent = { ...rest, kind: subagentKind, status: "running" };
|
|
582
|
+
const card = this.cardOfSubagent(id);
|
|
583
|
+
if (card === undefined) {
|
|
584
|
+
this.unplaced.set(id, born);
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
return this.patchCard(card, (tool) => this.place(tool, id, born));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// A subagent moves: present fields replace, absent ones leave it alone, as in tool_call_update; one still waiting for
|
|
591
|
+
// its card moves where it waits.
|
|
592
|
+
private subagentMoved(event: Extract<AgentEvent, { kind: "subagent_update" }>): TranscriptPatch[] {
|
|
593
|
+
const { kind: _kind, id, ...patch } = event;
|
|
594
|
+
const moved = defined(patch) as Partial<TranscriptSubagent>;
|
|
595
|
+
const card = this.cardOfSubagent(id);
|
|
596
|
+
if (card !== undefined) {
|
|
597
|
+
return this.patchCard(card, (tool) => {
|
|
598
|
+
if (tool.subagent !== undefined) {
|
|
599
|
+
tool.subagent = { ...tool.subagent, ...moved };
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
const waiting = this.unplaced.get(id);
|
|
604
|
+
if (waiting !== undefined) {
|
|
605
|
+
this.unplaced.set(id, { ...waiting, ...moved });
|
|
606
|
+
}
|
|
607
|
+
return [];
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// The card a subagent's frames land on: the one already carrying it, else the one sharing its id (an in-process
|
|
611
|
+
// subagent is named by the call that started it); undefined while the call that started it has not yet named it.
|
|
612
|
+
private cardOfSubagent(id: string): string | undefined {
|
|
613
|
+
return this.placed.get(id) ?? (this.cards.has(id) ? id : undefined);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Puts a subagent on the card that started it. Its own id rides along only where it differs from the card's, so the
|
|
617
|
+
// card can still name it to the roster, `wait` and its own page.
|
|
618
|
+
private place(tool: TranscriptTool, id: string, subagent: TranscriptSubagent): void {
|
|
619
|
+
const { id: _id, ...state } = subagent;
|
|
620
|
+
tool.subagent = id === tool.id ? state : { ...state, id };
|
|
621
|
+
this.placed.set(id, tool.id);
|
|
622
|
+
this.unplaced.delete(id);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// A card appearing under the id a subagent was already heard by: the stream said the child before the call.
|
|
626
|
+
private claimById(tool: TranscriptTool): void {
|
|
627
|
+
const waiting = this.unplaced.get(tool.id);
|
|
628
|
+
if (waiting !== undefined) {
|
|
629
|
+
this.place(tool, tool.id, waiting);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// A call whose result names a subagent still waiting for its card is the call that started it. A card carries one
|
|
634
|
+
// subagent, so one that already has its own claims nothing more.
|
|
635
|
+
private claimByResult(tool: TranscriptTool): void {
|
|
636
|
+
if (this.unplaced.size === 0 || tool.subagent !== undefined) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
const words = resultWords(tool);
|
|
640
|
+
for (const [id, waiting] of this.unplaced) {
|
|
641
|
+
if (words.includes(id)) {
|
|
642
|
+
this.place(tool, id, waiting);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
569
648
|
// Returns the bubble frames write to, opening a fresh assistant row when the last one was retired.
|
|
570
649
|
private open(): [number, TranscriptPatch[]] {
|
|
571
650
|
if (this.bubble !== undefined) {
|