@xingular/harness 0.1.7 → 2.1.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/CHANGELOG.md +20 -0
- package/README.md +36 -13
- package/dist/cli.js +10728 -8033
- package/dist/terminal-pi.js +365 -24
- package/docs/README.md +3 -0
- package/docs/decisions/ADR-0002-native-subagent-lifecycle.md +49 -0
- package/docs/decisions/ADR-0003-shared-asynchronous-subagents.md +70 -0
- package/docs/decisions/ADR-0004-fullscreen-terminal-layout.md +49 -0
- package/docs/decisions/ADR-0005-app-provided-oauth-clients.md +54 -0
- package/docs/terminal-tui.md +109 -13
- package/package.json +5 -4
- package/src/accounts/accounts.test.ts +87 -1
- package/src/accounts/accounts.ts +120 -18
- package/src/core/bus.test.ts +22 -0
- package/src/core/bus.ts +17 -2
- package/src/harness.ts +12 -2
- package/src/hub/hub.test.ts +49 -1
- package/src/hub/hub.ts +122 -6
- package/src/hub/schema.ts +13 -1
- package/src/index.ts +7 -2
- package/src/mcp/connections.ts +39 -12
- package/src/mcp/service.test.ts +49 -3
- package/src/mcp/service.ts +14 -3
- package/src/mcp/types.ts +10 -0
- package/src/memory/curation.test.ts +44 -1
- package/src/memory/dispatch.ts +5 -0
- package/src/memory/maintenance.test.ts +17 -1
- package/src/memory/maintenance.ts +169 -8
- package/src/memory/recall.ts +2 -1
- package/src/memory/trajectory.test.ts +178 -0
- package/src/memory/trajectory.ts +220 -0
- package/src/memory/wiki.ts +2 -1
- package/src/memory/wikiIndex.ts +3 -2
- package/src/plugins/builtin.ts +4 -2
- package/src/plugins/catalogue.test.ts +1 -1
- package/src/server/methods.ts +8 -0
- package/src/server/server.test.ts +20 -1
- package/src/sessions/approvals.test.ts +49 -0
- package/src/sessions/approvals.ts +26 -4
- package/src/sessions/conversation.test.ts +56 -0
- package/src/sessions/lineageJoin.ts +1 -1
- package/src/sessions/planMode.test.ts +40 -0
- package/src/sessions/planMode.ts +57 -0
- package/src/sessions/registry.ts +38 -7
- package/src/sessions/runStore.ts +14 -0
- package/src/sessions/runner.ts +9 -3
- package/src/sessions/sessions.test.ts +69 -7
- package/src/sessions/sessions.ts +47 -39
- package/src/sessions/subagentService.test.ts +159 -0
- package/src/sessions/subagentService.ts +236 -0
- package/src/sessions/subagents.ts +97 -0
- package/src/terminal/controller.ts +73 -10
- package/src/terminal/piExtension.ts +42 -6
- package/src/terminal/socket.test.ts +23 -0
- package/src/terminal/socket.ts +42 -18
- package/src/terminal/standalone.ts +12 -0
- package/src/terminal/terminal.test.ts +150 -15
- package/src/terminal/terminals.ts +113 -5
- package/src/terminal/trajectory.test.ts +66 -0
- package/src/terminal/trajectory.ts +44 -0
- package/src/terminal/tui/application.test.ts +206 -9
- package/src/terminal/tui/application.ts +251 -81
- package/src/terminal/tui/commands.test.ts +70 -9
- package/src/terminal/tui/commands.ts +99 -7
- package/src/terminal/tui/editor.test.ts +56 -27
- package/src/terminal/tui/editor.ts +64 -19
- package/src/terminal/tui/keybindings.test.ts +7 -0
- package/src/terminal/tui/keybindings.ts +9 -1
- package/src/terminal/tui/planMode.test.ts +10 -0
- package/src/terminal/tui/planMode.ts +2 -0
- package/src/terminal/tui/runtime.socket.test.ts +77 -3
- package/src/terminal/tui/runtime.test.ts +10 -0
- package/src/terminal/tui/runtime.ts +24 -9
- package/src/terminal/tui/screen.test.ts +143 -0
- package/src/terminal/tui/screen.ts +121 -0
- package/src/terminal/tui/skillInvocation.test.ts +71 -0
- package/src/terminal/tui/skillInvocation.ts +51 -0
- package/src/terminal/tui/status.test.ts +24 -46
- package/src/terminal/tui/status.ts +42 -69
- package/src/terminal/tui/theme.ts +18 -7
- package/src/terminal/tui/trajectoryPanel.test.ts +99 -0
- package/src/terminal/tui/trajectoryPanel.ts +226 -0
- package/src/terminal/tui/transcript.test.ts +14 -0
- package/src/terminal/tui/transcript.ts +11 -0
- package/src/terminal/tui/ui.ts +2 -2
- package/src/terminal/tui/welcome.ts +4 -4
- package/src/terminal/tui/workingStatus.test.ts +44 -0
- package/src/terminal/tui/workingStatus.ts +238 -0
- package/src/tools/definitions.ts +42 -1
- package/src/tools/maintenance.ts +6 -1
- package/src/tools/spawnSubagent.ts +40 -85
- package/src/tools/types.ts +4 -0
- package/docs/evidence/terminal-2026-10-17/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17/verification.json +0 -130
- package/docs/evidence/terminal-2026-10-17-followup/120x40-completed-transcript.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-interaction.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-settings-keyboard.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/40x20-welcome.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/app-bundle/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-approval-0.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-plugin-light.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question-batch.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/80x24-question.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/README.md +0 -27
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/shell-command.json +0 -18
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +0 -95
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-final-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/harness-reference-scenario.json +0 -60
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +0 -24
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/complete-80x24.ansi.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/verification.json +0 -57
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24-early.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-80x24.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/omp-reference-scenario.json +0 -58
- package/docs/evidence/terminal-2026-10-17-followup/omp-supplemental-static.json.gz +0 -0
- package/docs/evidence/terminal-2026-10-17-followup/rea-artifacts.json +0 -320
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +0 -30
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +0 -133
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +0 -20
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +0 -207
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Plan mode: a run that may only look, ask and recall, and ends with a proposed plan. The tool list
|
|
2
|
+
// the model sees is unchanged, so switching the mode does not change the cached prompt prefix; the
|
|
3
|
+
// gate refuses everything else, and the instruction arrives as a context update.
|
|
4
|
+
import { deliveredSnapshot, type HostSection } from '../prompt/compile'
|
|
5
|
+
import { ASK_USER } from '../tools/askUser'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read-only inspection, questions and recall. Everything else is refused in plan mode: file
|
|
9
|
+
* changes, commands, memory writes, delegation and connected apps, since none of them can be
|
|
10
|
+
* judged read-only from their names. A provider marks its own read-only tools with `readOnly`.
|
|
11
|
+
*/
|
|
12
|
+
export const PLAN_READ_ONLY_TOOLS: ReadonlySet<string> = new Set([
|
|
13
|
+
'read', 'grep', 'find', 'ls', ASK_USER, 'ask_user_question',
|
|
14
|
+
'history_search', 'history_read', 'trajectory_search', 'trajectory_read',
|
|
15
|
+
'wiki_entrypoint', 'wiki_search', 'wiki_read', 'wiki_list', 'memory_facts', 'skill_load', 'mcp_list_tools'
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export const PLAN_SECTION_ID = 'plan'
|
|
19
|
+
/** Between the application's role and the memory blocks. */
|
|
20
|
+
export const PLAN_SECTION_ORDER = 25
|
|
21
|
+
export const PLAN_OPEN_TAG = '<proposed_plan>'
|
|
22
|
+
export const PLAN_CLOSE_TAG = '</proposed_plan>'
|
|
23
|
+
|
|
24
|
+
export const PLAN_INSTRUCTIONS = `Plan mode is on for this request. Investigate and design; do not carry out the work.
|
|
25
|
+
- Read, search and recall freely. When a decision belongs to the user, ask with ${ASK_USER} instead of guessing.
|
|
26
|
+
- Do not change files, run commands, write memory, delegate, or call connected apps. Such calls are refused while plan mode is on.
|
|
27
|
+
- When the plan is ready, end the reply with exactly one block in this form, after any other text:
|
|
28
|
+
${PLAN_OPEN_TAG}
|
|
29
|
+
# <Plan title>
|
|
30
|
+
<Markdown: the context, the steps with the files they change, and how to verify the result>
|
|
31
|
+
${PLAN_CLOSE_TAG}
|
|
32
|
+
- If you need the user's input before a plan is possible, ask instead of proposing one.`
|
|
33
|
+
|
|
34
|
+
/** The tools a plan-mode run may call: the read-only set and the provider tools marked read-only. */
|
|
35
|
+
export function planModeTools(normal: readonly string[], readOnly: Iterable<string> = []): string[] {
|
|
36
|
+
const allowed = new Set([...PLAN_READ_ONLY_TOOLS, ...readOnly])
|
|
37
|
+
return normal.filter((name) => allowed.has(name))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Why a plan-mode run may not call this tool, or null when it may. */
|
|
41
|
+
export function planRefusal(tool: string, readOnly: ReadonlySet<string>): string | null {
|
|
42
|
+
if (PLAN_READ_ONLY_TOOLS.has(tool) || readOnly.has(tool)) return null
|
|
43
|
+
return `Plan mode is read-only: ${tool} is not available until the user leaves plan mode. Continue investigating, or finish with a ${PLAN_OPEN_TAG} block.`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The plan section for this request. While plan mode is on it carries the instructions; on the
|
|
48
|
+
* first request after it, the empty slot withdraws the earlier instructions from the conversation.
|
|
49
|
+
* Otherwise there is no section, so a conversation that never planned is delivered exactly as before.
|
|
50
|
+
*/
|
|
51
|
+
export function planSection(plan: boolean, historyFile: string | null): HostSection | null {
|
|
52
|
+
if (plan) return { id: PLAN_SECTION_ID, title: 'Plan mode', body: PLAN_INSTRUCTIONS, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
|
|
53
|
+
const delivered = historyFile ? deliveredSnapshot(historyFile) : null
|
|
54
|
+
return delivered?.some((entry) => entry.id === PLAN_SECTION_ID)
|
|
55
|
+
? { id: PLAN_SECTION_ID, title: 'Plan mode', body: null, budget: 2000, order: PLAN_SECTION_ORDER, dynamic: true }
|
|
56
|
+
: null
|
|
57
|
+
}
|
package/src/sessions/registry.ts
CHANGED
|
@@ -91,6 +91,7 @@ export class RunRegistry {
|
|
|
91
91
|
private readonly approvals = new Map<string, { runId: string; resolve: (decision: ApprovalDecision) => void }>()
|
|
92
92
|
private readonly questions = new Map<string, PendingQuestion>()
|
|
93
93
|
private readonly nativeCleared = new Map<string, string>()
|
|
94
|
+
private readonly waiters = new Map<string, Array<() => void>>()
|
|
94
95
|
private readonly stopping = new Set<string>()
|
|
95
96
|
private readonly listeners: RunListener[] = []
|
|
96
97
|
|
|
@@ -130,6 +131,7 @@ export class RunRegistry {
|
|
|
130
131
|
sequence = 0
|
|
131
132
|
}
|
|
132
133
|
let finished = false
|
|
134
|
+
let finishing = false
|
|
133
135
|
const interruptHandlers: Array<() => Promise<void> | void> = []
|
|
134
136
|
let steerHandler: ((text: string) => Promise<void> | void) | null = null
|
|
135
137
|
const waitingStatus = (): string =>
|
|
@@ -138,7 +140,7 @@ export class RunRegistry {
|
|
|
138
140
|
const requestQuestion = (input: QuestionInput, batch?: QuestionBatchMetadata, statusEvent = true): Promise<QuestionAnswer> => {
|
|
139
141
|
const { requestId, ...payload } = input
|
|
140
142
|
const questionId = requestId ?? randomUUID()
|
|
141
|
-
if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
|
|
143
|
+
if (finished || finishing || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
|
|
142
144
|
if (this.questions.has(questionId)) throw new HarnessError('invalid_request', 'This question id is already pending.')
|
|
143
145
|
return new Promise<QuestionAnswer>((resolve) => {
|
|
144
146
|
this.questions.set(questionId, {
|
|
@@ -186,7 +188,7 @@ export class RunRegistry {
|
|
|
186
188
|
// What the user already allowed is known here; if it cannot be read, ask as usual.
|
|
187
189
|
const record = findRun(this.options.root(), runId)
|
|
188
190
|
const automatic = record && this.options.automatic && standing !== 'none' ? await this.options.automatic(record, input.target, input.operation, standing).catch(() => null) : null
|
|
189
|
-
if (finished) return 'denied'
|
|
191
|
+
if (finished || finishing) return 'denied'
|
|
190
192
|
if (this.nativeCleared.delete(approvalId)) return 'native'
|
|
191
193
|
if (automatic) {
|
|
192
194
|
// Marked on the request too, so nothing treats it as waiting on someone.
|
|
@@ -219,22 +221,26 @@ export class RunRegistry {
|
|
|
219
221
|
if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some((id) => this.questions.has(id))) {
|
|
220
222
|
throw new HarnessError('invalid_request', 'Question ids in a batch must be unique and not already pending.')
|
|
221
223
|
}
|
|
222
|
-
if (finished) return Promise.resolve(inputs.map(() => ({ selected: [] })))
|
|
224
|
+
if (finished || finishing) return Promise.resolve(inputs.map(() => ({ selected: [] })))
|
|
223
225
|
const batchId = randomUUID()
|
|
224
226
|
const answers = inputs.map((input, index) => requestQuestion(input, { batchId, batchIndex: index + 1, batchCount: inputs.length }, false))
|
|
225
227
|
emit('status.changed', { status: 'waiting_for_input' })
|
|
226
228
|
return Promise.all(answers)
|
|
227
229
|
},
|
|
228
230
|
finish: (outcome, failure) => {
|
|
229
|
-
if (finished) return
|
|
231
|
+
if (finished || finishing) return
|
|
232
|
+
finishing = true
|
|
233
|
+
// Persist cancellation receipts while emit is still open, so every interface clears them.
|
|
234
|
+
for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
|
|
235
|
+
for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
|
|
230
236
|
if (outcome === 'failed') emit('failed', failure ?? { code: 'failed', message: 'The run failed.' })
|
|
231
237
|
else if (outcome === 'interrupted') emit('status.changed', { status: 'interrupted' })
|
|
232
238
|
else emit('completed', {})
|
|
233
239
|
finished = true
|
|
234
240
|
this.runs.delete(runId)
|
|
241
|
+
for (const resolve of [...(this.waiters.get(runId) ?? [])]) resolve()
|
|
242
|
+
this.waiters.delete(runId)
|
|
235
243
|
for (const [id, owner] of this.nativeCleared) if (owner === runId) this.nativeCleared.delete(id)
|
|
236
|
-
for (const [, approval] of [...this.approvals]) if (approval.runId === runId) approval.resolve('denied')
|
|
237
|
-
for (const [, question] of [...this.questions]) if (question.runId === runId) question.resolve({ selected: [] })
|
|
238
244
|
try {
|
|
239
245
|
this.options.ended?.(runId, sessionId)
|
|
240
246
|
} catch (error) {
|
|
@@ -245,6 +251,8 @@ export class RunRegistry {
|
|
|
245
251
|
return finished
|
|
246
252
|
},
|
|
247
253
|
interrupt: async () => {
|
|
254
|
+
// Release approvals/questions before SDK abort waits for their blocked tools.
|
|
255
|
+
run.finish('interrupted')
|
|
248
256
|
for (const handler of interruptHandlers) {
|
|
249
257
|
try {
|
|
250
258
|
await handler()
|
|
@@ -252,7 +260,6 @@ export class RunRegistry {
|
|
|
252
260
|
log(`an interrupt handler failed for run ${runId}`, error instanceof Error ? error.message : String(error))
|
|
253
261
|
}
|
|
254
262
|
}
|
|
255
|
-
run.finish('interrupted')
|
|
256
263
|
}
|
|
257
264
|
}
|
|
258
265
|
|
|
@@ -278,6 +285,30 @@ export class RunRegistry {
|
|
|
278
285
|
return false
|
|
279
286
|
}
|
|
280
287
|
|
|
288
|
+
/** Wait for a recorded run to settle without polling storage or retaining an event listener. */
|
|
289
|
+
wait(runId: string, signal?: AbortSignal): Promise<void> {
|
|
290
|
+
const record = findRun(this.options.root(), runId)
|
|
291
|
+
if (!record) throw new HarnessError('not_found', 'Run was not found.')
|
|
292
|
+
if (!isActiveStatus(record.status)) return Promise.resolve()
|
|
293
|
+
if (!this.runs.has(runId)) throw new HarnessError('run_not_active', 'This run is not active in this process.')
|
|
294
|
+
if (signal?.aborted) return Promise.reject(new HarnessError('run_not_active', 'The wait was interrupted.'))
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const cleanup = () => {
|
|
297
|
+
signal?.removeEventListener('abort', abort)
|
|
298
|
+
const pending = this.waiters.get(runId)
|
|
299
|
+
const index = pending?.indexOf(done) ?? -1
|
|
300
|
+
if (index >= 0) pending!.splice(index, 1)
|
|
301
|
+
if (!pending?.length) this.waiters.delete(runId)
|
|
302
|
+
}
|
|
303
|
+
const done = () => { cleanup(); resolve() }
|
|
304
|
+
const abort = () => { cleanup(); reject(new HarnessError('run_not_active', 'The wait was interrupted.')) }
|
|
305
|
+
const pending = this.waiters.get(runId) ?? []
|
|
306
|
+
pending.push(done)
|
|
307
|
+
this.waiters.set(runId, pending)
|
|
308
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
281
312
|
/** The live run with this id, for a tool or broker that reports into it. */
|
|
282
313
|
active(runId: string): ActiveRun | null {
|
|
283
314
|
return this.runs.get(runId) ?? null
|
package/src/sessions/runStore.ts
CHANGED
|
@@ -74,6 +74,20 @@ export function findRun(root: string, runId: string): RunRecord | null {
|
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** A parent's reserved child IDs survive a crash before the spawn tool returns. */
|
|
78
|
+
export function recordSubagent(root: string, parentRunId: string, childRunId: string): void {
|
|
79
|
+
loadRun(root, parentRunId)
|
|
80
|
+
const ids = recordedSubagents(root, parentRunId)
|
|
81
|
+
if (!ids.includes(childRunId)) writeJson(join(runDir(root, parentRunId), 'subagents.json'), [...ids, childRunId])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function recordedSubagents(root: string, parentRunId: string): string[] {
|
|
85
|
+
const value = readJson(join(runDir(root, parentRunId), 'subagents.json'))
|
|
86
|
+
if (value === null) return []
|
|
87
|
+
if (!Array.isArray(value) || value.some(id => typeof id !== 'string')) throw new Error('Recorded subagent ownership is unreadable.')
|
|
88
|
+
return value as string[]
|
|
89
|
+
}
|
|
90
|
+
|
|
77
91
|
/** Changes a run's record. A settled run keeps its outcome whatever the change says. */
|
|
78
92
|
export function updateRun(root: string, runId: string, update: (record: RunRecord) => void): void {
|
|
79
93
|
const record = readRecord(recordPath(root, runId))
|
package/src/sessions/runner.ts
CHANGED
|
@@ -23,6 +23,9 @@ export type Effort = (typeof EFFORTS)[number]
|
|
|
23
23
|
/** What one turn needs once its prompt is compiled and its tools are chosen. */
|
|
24
24
|
export interface TurnParams {
|
|
25
25
|
runId: string
|
|
26
|
+
/** Child runs may not gain tools registered by extensions outside their snapshot. */
|
|
27
|
+
restrictTools?: boolean
|
|
28
|
+
settle?: () => Promise<void>
|
|
26
29
|
cwd: string
|
|
27
30
|
sessionDir: string
|
|
28
31
|
/** The native history to open, or `null` to start a fresh one. */
|
|
@@ -77,9 +80,11 @@ export type PiEvent =
|
|
|
77
80
|
|
|
78
81
|
export interface PiSessionLike {
|
|
79
82
|
sessionId: string
|
|
83
|
+
sessionManager?: { getBranch(): unknown[] }
|
|
84
|
+
thinkingLevel?: Effort
|
|
80
85
|
systemPrompt?: string
|
|
81
86
|
extensionCommands?: readonly string[]
|
|
82
|
-
model?: { input: string[]; contextWindow?: number }
|
|
87
|
+
model?: { input: string[]; contextWindow?: number; id?: string; provider?: string }
|
|
83
88
|
getActiveToolNames?(): string[]
|
|
84
89
|
getAllTools?(): Array<{ name: string; description?: string; parameters?: unknown }>
|
|
85
90
|
subscribe(listener: (event: PiEvent) => void): () => void
|
|
@@ -460,6 +465,7 @@ export async function runTurn(run: ActiveRun, session: PiSessionLike, params: Tu
|
|
|
460
465
|
accepting = false
|
|
461
466
|
session.clearQueue?.()
|
|
462
467
|
}
|
|
468
|
+
if (!aborted && !lastError) await params.settle?.()
|
|
463
469
|
if (aborted) run.finish('interrupted')
|
|
464
470
|
else if (lastError) run.finish('failed', { code: 'provider_error', message: lastError })
|
|
465
471
|
else run.finish('completed')
|
|
@@ -540,7 +546,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
|
|
|
540
546
|
const loaded = registeredBy(resourceLoader)
|
|
541
547
|
// A tool an extension brought is offered to the model, and asks before every call: the person
|
|
542
548
|
// trusted the code to load, and still decides each thing it does through a tool.
|
|
543
|
-
const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name))
|
|
549
|
+
const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name) && !params.restrictTools)
|
|
544
550
|
gate.ask?.([...new Set(loaded.flatMap((extension) => extension.tools))])
|
|
545
551
|
|
|
546
552
|
let supportsImageInput = false
|
|
@@ -558,7 +564,7 @@ export const piSessionFactory: SessionFactory = async ({ params, allow, gate, to
|
|
|
558
564
|
if (output.image && !supportsImageInput) throw new HarnessError('image_input_unsupported', 'This model cannot inspect images. Choose a model that supports image input.')
|
|
559
565
|
return {
|
|
560
566
|
content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never,
|
|
561
|
-
details: output.attachments?.length ? { attachments: output.attachments } : {}
|
|
567
|
+
details: { ...output.details, ...(output.attachments?.length ? { attachments: output.attachments } : {}) }
|
|
562
568
|
}
|
|
563
569
|
}
|
|
564
570
|
})
|
|
@@ -400,6 +400,29 @@ describe('session turns on Pi', () => {
|
|
|
400
400
|
expect(contexts).toEqual([policyContext])
|
|
401
401
|
})
|
|
402
402
|
|
|
403
|
+
it('refuses non-read-only calls in plan mode and delivers its instructions without changing the system prompt', async () => {
|
|
404
|
+
const seen: { system: string; update: string | null; reasons: (string | null)[] }[] = []
|
|
405
|
+
const { run, harness } = setup(async ({ gate, params }) => fakeSession(async () => {
|
|
406
|
+
const reasons = [await gate.check('bash', {}), await gate.check('write', {}), await gate.check('memory_append', {}), await gate.check('lookup', {}), await gate.check('change', {})]
|
|
407
|
+
seen.push({ system: params.system, update: params.update?.content ?? null, reasons })
|
|
408
|
+
}, gate))
|
|
409
|
+
harness.approvals.setAuto('chat', true)
|
|
410
|
+
harness.sessions.provide(() => [
|
|
411
|
+
{ name: 'lookup', label: 'Lookup', description: 'Reads', readOnly: true, parameters: (Type) => Type.Object({}), execute: async () => 'ok' },
|
|
412
|
+
{ name: 'change', label: 'Change', description: 'Writes', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }
|
|
413
|
+
])
|
|
414
|
+
await run('plan-run', { plan: true, tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
|
|
415
|
+
await tick()
|
|
416
|
+
await run('normal-run', { tools: { allow: ['read', 'bash', 'write', 'lookup', 'change'] } })
|
|
417
|
+
await tick()
|
|
418
|
+
const [planned, normal] = seen
|
|
419
|
+
expect(planned.reasons.map((reason) => reason?.startsWith('Plan mode is read-only') ?? null)).toEqual([true, true, true, null, true])
|
|
420
|
+
expect(normal.reasons).toEqual([null, null, null, null, null])
|
|
421
|
+
expect(planned.system).toBe(normal.system)
|
|
422
|
+
expect(planned.update).toContain('<proposed_plan>')
|
|
423
|
+
expect(normal.update ?? '').not.toContain('Plan mode is on')
|
|
424
|
+
})
|
|
425
|
+
|
|
403
426
|
it('aborts the Pi session on interrupt', async () => {
|
|
404
427
|
let session: (PiSessionLike & { aborted: boolean }) | null = null
|
|
405
428
|
const { events, run, harness } = setup(async ({ gate }) => (session = fakeSession(() => new Promise((resolve) => setTimeout(resolve, 50)), gate)))
|
|
@@ -423,7 +446,7 @@ describe('session turns on Pi', () => {
|
|
|
423
446
|
await tick()
|
|
424
447
|
expect(received!.allow.slice(0, 3)).toEqual(['react', 'ask_user', 'plugin_propose'])
|
|
425
448
|
expect(received!.allow).toEqual(expect.arrayContaining(['memory_append', 'history_search', 'wiki_search', 'wiki_read', 'wiki_propose']))
|
|
426
|
-
expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'ask_user', 'plugin_propose', 'react'])
|
|
449
|
+
expect(received!.tools.map((tool) => tool.name)).toEqual(['memory_append', 'memory_replace', 'memory_rethink', 'history_search', 'history_read', 'wiki_search', 'wiki_read', 'wiki_list', 'skill_load', 'memory_facts', 'wiki_propose', 'trajectory_search', 'trajectory_read', 'ask_user', 'plugin_propose', 'spawn_subagent', 'wait_subagents', 'react'])
|
|
427
450
|
expect(await received!.tools[0].execute({ block: 'notes', text: 'Remember this.' })).toContain('Updated the notes block')
|
|
428
451
|
expect(received!.params.system).toContain('# Xingular Harness context')
|
|
429
452
|
})
|
|
@@ -496,7 +519,7 @@ describe('subagents', () => {
|
|
|
496
519
|
if (params.runId === 'parent') {
|
|
497
520
|
const spawn = tools.find((tool) => tool.name === 'spawn_subagent')!
|
|
498
521
|
expect(await spawn.execute({ brief: 'Find the bug', tools: ['bash', 'web'] })).toMatch(/cannot have tools you do not have: web/)
|
|
499
|
-
parentResult = await spawn.execute({ brief: 'Find the bug', tools: ['read'] })
|
|
522
|
+
parentResult = await spawn.execute({ brief: 'Find the bug', tools: ['read'], wait: true })
|
|
500
523
|
return
|
|
501
524
|
}
|
|
502
525
|
emit({ type: 'message_start', message: { role: 'assistant' } })
|
|
@@ -505,7 +528,7 @@ describe('subagents', () => {
|
|
|
505
528
|
})
|
|
506
529
|
await run('parent', { subagents: { maxDepth: 1 } })
|
|
507
530
|
for (let attempt = 0; attempt < 50 && !parentResult; attempt++) await tick()
|
|
508
|
-
expect(parentResult).
|
|
531
|
+
expect(parentResult).toMatchObject({ text: expect.stringContaining('Found it in parser.ts'), details: expect.any(Object) })
|
|
509
532
|
expect(seen.get('parent')!.allow).toContain('spawn_subagent')
|
|
510
533
|
const [childId] = [...seen.keys()].filter((id) => id !== 'parent')
|
|
511
534
|
const child = seen.get(childId)!
|
|
@@ -516,14 +539,53 @@ describe('subagents', () => {
|
|
|
516
539
|
expect(loadRun(harness.env.root, childId)).toMatchObject({ parentRunId: 'parent', relation: 'subagent', agentId: 'agent-1', status: 'completed' })
|
|
517
540
|
})
|
|
518
541
|
|
|
519
|
-
it('
|
|
542
|
+
it('exposes native child runs, freezes the launch, respects nesting, and settles waiters on parent stop', async () => {
|
|
543
|
+
const seen = new Map<string, string[]>()
|
|
544
|
+
const { harness, run } = setup(async ({ gate, params, allow }) => {
|
|
545
|
+
seen.set(params.runId, [...allow])
|
|
546
|
+
return fakeSession(async () => new Promise<void>(() => undefined), gate)
|
|
547
|
+
})
|
|
548
|
+
const subagents = { maxDepth: 2 }
|
|
549
|
+
const starting = run('parent', { subagents })
|
|
550
|
+
subagents.maxDepth = 3
|
|
551
|
+
await starting
|
|
552
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Bad tools', tools: ['outside'] })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
553
|
+
const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Search' })
|
|
554
|
+
const grandchild = await harness.sessions.spawnSubagent({ parentRunId: child.runId, brief: 'Review' })
|
|
555
|
+
expect(seen.get(child.runId)).toContain('spawn_subagent')
|
|
556
|
+
expect(seen.get(grandchild.runId)).not.toContain('spawn_subagent')
|
|
557
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: grandchild.runId, brief: 'Too deep' })).rejects.toMatchObject({ code: 'invalid_request' })
|
|
558
|
+
const narrowed = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Read only', tools: ['read'] })
|
|
559
|
+
expect(seen.get(narrowed.runId)).toEqual(['read'])
|
|
560
|
+
const settled = Promise.all([child, grandchild, narrowed].map(({ runId }) => harness.runs.wait(runId)))
|
|
561
|
+
await harness.runs.interrupt('parent')
|
|
562
|
+
await settled
|
|
563
|
+
for (const { runId } of [child, grandchild, narrowed]) expect(loadRun(harness.env.root, runId).status).toBe('interrupted')
|
|
564
|
+
await expect(harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Too late' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
565
|
+
await harness.runs.wait(child.runId)
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
it('settles a failed child and attributes the tool to the Harness plugin', async () => {
|
|
569
|
+
const { harness, run } = setup(async ({ gate, params }) => {
|
|
570
|
+
if (params.runId !== 'parent') throw new Error('No provider')
|
|
571
|
+
return fakeSession(async () => new Promise<void>(() => undefined), gate)
|
|
572
|
+
})
|
|
573
|
+
await run('parent', { subagents: {} })
|
|
574
|
+
const child = await harness.sessions.spawnSubagent({ parentRunId: 'parent', brief: 'Try' })
|
|
575
|
+
await harness.runs.wait(child.runId)
|
|
576
|
+
expect(loadRun(harness.env.root, child.runId)).toMatchObject({ status: 'failed', parentRunId: 'parent', terminalReason: 'No provider' })
|
|
577
|
+
const { HARNESS_PLUGINS } = await import('../plugins/builtin')
|
|
578
|
+
expect(HARNESS_PLUGINS.find((plugin) => plugin.id === 'subagents')?.tools).toContainEqual(expect.objectContaining({ name: 'spawn_subagent' }))
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('can explicitly disable subagents and run without the application\'s tool providers', async () => {
|
|
520
582
|
const offered: string[][] = []
|
|
521
583
|
const { harness, run } = setup(async ({ gate, tools }) => {
|
|
522
584
|
offered.push(tools.map((tool) => tool.name))
|
|
523
585
|
return fakeSession(async () => undefined, gate)
|
|
524
586
|
})
|
|
525
587
|
harness.sessions.provide(() => [{ name: 'app_tool', label: 'App', description: 'An app tool', parameters: (Type) => Type.Object({}), execute: async () => 'ok' }])
|
|
526
|
-
await run('plain', { tools: { allow: ['app_tool'] } })
|
|
588
|
+
await run('plain', { tools: { allow: ['app_tool'] }, subagents: null })
|
|
527
589
|
await run('solo', { tools: { allow: ['app_tool'] }, providers: false })
|
|
528
590
|
await tick()
|
|
529
591
|
expect(offered[0]).toContain('app_tool')
|
|
@@ -533,7 +595,7 @@ describe('subagents', () => {
|
|
|
533
595
|
})
|
|
534
596
|
|
|
535
597
|
describe('standing approvals', () => {
|
|
536
|
-
const policy = { auto: { pi: true }, remembered: { 'pane-1': ['shell'] } }
|
|
598
|
+
const policy = { auto: { pi: true }, globalTools: {}, remembered: { 'pane-1': ['shell'] } }
|
|
537
599
|
const facts = (unattended: boolean, readOnly: boolean): ApprovalFacts => ({ unattended, readOnly })
|
|
538
600
|
|
|
539
601
|
it('the switch reaches attended sessions but not unattended or read-only ones', () => {
|
|
@@ -544,6 +606,6 @@ describe('standing approvals', () => {
|
|
|
544
606
|
expect(grant(policy, 'pi', 'pane-1', 'shell', facts(true, false))).toBeNull()
|
|
545
607
|
expect(grant(policy, 'chat', 'pane-1', 'edit files', facts(false, false))).toBeNull()
|
|
546
608
|
expect(grant(policy, 'pi', 'pane-2', 'shell', {})).toBe('always')
|
|
547
|
-
expect(grant({ auto: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
|
|
609
|
+
expect(grant({ auto: {}, globalTools: {}, remembered: policy.remembered }, 'pi', 'pane-2', 'shell', {})).toBeNull()
|
|
548
610
|
})
|
|
549
611
|
})
|
package/src/sessions/sessions.ts
CHANGED
|
@@ -21,17 +21,20 @@ import { FIRST_PARTY_PLUGINS, firstPartyExtensions, type InlineExtension } from
|
|
|
21
21
|
import * as registry from '../skills/registry'
|
|
22
22
|
import { ASK_USER, askUserTool } from '../tools/askUser'
|
|
23
23
|
import { PLUGIN_PROPOSE, pluginProposeTool } from '../tools/pluginPropose'
|
|
24
|
-
import {
|
|
24
|
+
import { subagentTools } from '../tools/spawnSubagent'
|
|
25
25
|
import { WIKI_TOOL_NAMES } from '../tools/definitions'
|
|
26
26
|
import { memoryTools } from '../tools/memoryTools'
|
|
27
27
|
import type { ToolProvider, ToolSession, ToolSpec } from '../tools/types'
|
|
28
28
|
import type { RunRecord } from './contracts'
|
|
29
|
+
import { SPAWN_SUBAGENT, WAIT_SUBAGENTS, subagentDepth, subagentHistory, subagentNesting, type SubagentInput, type SubagentOptions } from './subagents'
|
|
30
|
+
import type { Subagents } from './subagentService'
|
|
29
31
|
import { selectHistory } from './history'
|
|
30
32
|
import { runLineageJoin } from './lineageJoin'
|
|
31
33
|
import type { ToolPolicy } from './policy'
|
|
32
34
|
import type { ActiveRun, RunRegistry } from './registry'
|
|
33
35
|
import { piSessionFactory, runTurn, summarize, type Effort, type PiSessionLike, type SessionFactory, type ToolGate, type TurnParams } from './runner'
|
|
34
36
|
import { loadRun, runDir, saveRun, updateRun } from './runStore'
|
|
37
|
+
import { planRefusal, planSection } from './planMode'
|
|
35
38
|
|
|
36
39
|
/** The fields of a run record the caller decides; the harness fills in the rest. */
|
|
37
40
|
export type RunFields = Pick<RunRecord, 'id' | 'sessionId'> & Partial<Pick<RunRecord, 'mode' | 'harness' | 'sessionType' | 'projectId' | 'agentId' | 'trigger' | 'goal' | 'attachmentCount' | 'resumedFromRunId' | 'parentRunId' | 'relation' | 'worktreeId' | 'handoffId'>>
|
|
@@ -67,10 +70,15 @@ export interface SessionRunInput {
|
|
|
67
70
|
capabilities?: CapabilitySet
|
|
68
71
|
/** Hops from the user to this session (0 when the user started it). */
|
|
69
72
|
depth?: number
|
|
70
|
-
/**
|
|
73
|
+
/** Interactive default: native child tools. Null disables; unattended callers must opt in. */
|
|
71
74
|
subagents?: SubagentOptions | null
|
|
72
75
|
/** Ask the application's tool providers for tools (the default). False runs with the harness's own tools only. */
|
|
73
76
|
providers?: boolean
|
|
77
|
+
/**
|
|
78
|
+
* Plan mode: the run may only read, search, recall and ask, and is told to end with a
|
|
79
|
+
* `<proposed_plan>` block. Other calls are refused at the gate; the tool list is unchanged.
|
|
80
|
+
*/
|
|
81
|
+
plan?: boolean
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/** The connected-apps broker, as a session sees it. */
|
|
@@ -80,6 +88,7 @@ export interface SessionBroker {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
export interface SessionsOptions {
|
|
91
|
+
subagents: Subagents
|
|
83
92
|
env: DispatchEnv
|
|
84
93
|
accounts: Accounts
|
|
85
94
|
runs: RunRegistry
|
|
@@ -105,6 +114,7 @@ export class Sessions {
|
|
|
105
114
|
|
|
106
115
|
constructor(private readonly options: SessionsOptions) {
|
|
107
116
|
this.factory = options.factory ?? piSessionFactory
|
|
117
|
+
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
private get env(): HarnessEnv {
|
|
@@ -151,33 +161,9 @@ export class Sessions {
|
|
|
151
161
|
}
|
|
152
162
|
}
|
|
153
163
|
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return spawnSubagentTool({
|
|
158
|
-
root: this.env.root,
|
|
159
|
-
parentRunId: runId,
|
|
160
|
-
parentSessionId: input.run.sessionId,
|
|
161
|
-
parentTools,
|
|
162
|
-
sessionDir: input.sessionDir,
|
|
163
|
-
start: (child) => this.run({
|
|
164
|
-
...input,
|
|
165
|
-
run: { ...input.run, id: child.runId, sessionId: child.sessionId, parentRunId: runId, relation: SUBAGENT_RELATION, goal: takeChars(child.brief, 4000), resumedFromRunId: null, handoffId: null },
|
|
166
|
-
accountId,
|
|
167
|
-
poolId: null,
|
|
168
|
-
sessionDir: child.sessionDir,
|
|
169
|
-
resume: null,
|
|
170
|
-
text: child.brief,
|
|
171
|
-
images: undefined,
|
|
172
|
-
requiresImageInput: false,
|
|
173
|
-
preamble: null,
|
|
174
|
-
caller: { ...input.caller, runId: undefined, sessionId: child.sessionId },
|
|
175
|
-
tools: { allow: [...child.tools, SPAWN_SUBAGENT], gated: (input.tools.gated ?? []).filter((name) => child.tools.includes(name)) },
|
|
176
|
-
// Never wider than the parent's frozen set; the session intersects it with what is available now.
|
|
177
|
-
capabilities: { ...parent, tools: [...parent.tools.filter((name) => child.tools.includes(name)), SPAWN_SUBAGENT] },
|
|
178
|
-
depth: (input.depth ?? 0) + 1
|
|
179
|
-
})
|
|
180
|
-
})
|
|
164
|
+
/** Compatibility alias for the shared native service. */
|
|
165
|
+
spawnSubagent(request: SubagentInput): Promise<{ runId: string; nativeSessionId: string | null }> {
|
|
166
|
+
return this.options.subagents.spawn(request)
|
|
181
167
|
}
|
|
182
168
|
|
|
183
169
|
/**
|
|
@@ -187,6 +173,7 @@ export class Sessions {
|
|
|
187
173
|
async run(input: SessionRunInput): Promise<{ runId: string; nativeSessionId: string | null }> {
|
|
188
174
|
const { root } = this.env
|
|
189
175
|
if (!input?.run?.id || !input.run.sessionId || typeof input.text !== 'string') throw invalid('A session turn needs a run id, a session, and text.')
|
|
176
|
+
input = structuredClone(input)
|
|
190
177
|
const historyFile = selectHistory({ sessionDir: input.sessionDir, resume: input.resume })
|
|
191
178
|
const account = this.options.accounts.resolve(input.accountId, input.poolId)
|
|
192
179
|
const context = this.options.accounts.context(account)
|
|
@@ -194,14 +181,14 @@ export class Sessions {
|
|
|
194
181
|
const caller: CallerContext = { ...input.caller, maintenanceJobIds: input.caller.maintenanceJobIds?.slice(), runId, sessionId: input.caller.sessionId ?? input.run.sessionId }
|
|
195
182
|
const broker = input.connections ? this.options.broker : null
|
|
196
183
|
const depth = input.depth ?? 0
|
|
197
|
-
const canSpawn =
|
|
184
|
+
const canSpawn = input.subagents !== null && (!input.automatic || input.subagents !== undefined) && subagentNesting(root, input.run) < subagentDepth(input.subagents ?? undefined)
|
|
198
185
|
const requestedTools = [...new Set([
|
|
199
|
-
...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT),
|
|
186
|
+
...input.tools.allow.filter((name) => name !== SPAWN_SUBAGENT && name !== WAIT_SUBAGENTS),
|
|
200
187
|
ASK_USER,
|
|
201
188
|
...(input.automatic ? [] : [PLUGIN_PROPOSE]),
|
|
202
189
|
...WIKI_TOOL_NAMES,
|
|
203
190
|
...(broker?.toolNames ?? []),
|
|
204
|
-
...(canSpawn ? [SPAWN_SUBAGENT] : [])
|
|
191
|
+
...(canSpawn ? [SPAWN_SUBAGENT, WAIT_SUBAGENTS] : [])
|
|
205
192
|
])]
|
|
206
193
|
const available = this.options.resolveCapabilities
|
|
207
194
|
? await this.options.resolveCapabilities(caller, requestedTools, input.cwd)
|
|
@@ -259,8 +246,10 @@ export class Sessions {
|
|
|
259
246
|
try {
|
|
260
247
|
if (!existsSync(input.sessionDir)) mkdirSync(input.sessionDir, { recursive: true })
|
|
261
248
|
const launch = input.launch ?? {}
|
|
249
|
+
const plan = planSection(input.plan === true, historyFile)
|
|
262
250
|
const effectiveLaunch = {
|
|
263
251
|
...launch,
|
|
252
|
+
sections: plan ? [...(launch.sections ?? []).filter((section) => section.id !== plan.id), plan] : launch.sections,
|
|
264
253
|
tools: capabilities.tools,
|
|
265
254
|
connectionTools: (launch.connectionTools ?? []).filter((name) => capabilities.tools.includes(name)),
|
|
266
255
|
toolGroups: launch.toolGroups?.map((group) => ({ ...group, tools: group.tools.filter((name) => capabilities.tools.includes(name)) })).filter((group) => group.tools.length > 0)
|
|
@@ -278,18 +267,20 @@ export class Sessions {
|
|
|
278
267
|
const session: ToolSession = { run, caller, allow, depth, cwd: input.cwd, capabilities }
|
|
279
268
|
const { plugins } = this.options
|
|
280
269
|
|
|
281
|
-
let pi: PiSessionLike
|
|
270
|
+
let pi: PiSessionLike | undefined
|
|
282
271
|
try {
|
|
283
272
|
const tools: ToolSpec[] = [
|
|
284
273
|
...(broker ? broker.tools(session, { readOnly: typeof input.connections === 'object' && input.connections.readOnly === true }) : []),
|
|
285
274
|
...memoryTools(this.options.env, caller, capabilities.contributions),
|
|
286
275
|
askUserTool(run),
|
|
287
276
|
pluginProposeTool(this.options.env, caller),
|
|
288
|
-
...(
|
|
277
|
+
...subagentTools(this.options.subagents, runId).filter(tool => allow.includes(tool.name))
|
|
289
278
|
]
|
|
290
279
|
if (input.providers !== false) for (const provider of this.providers) tools.push(...(await provider(session)))
|
|
280
|
+
const readOnly = new Set(tools.filter((tool) => tool.readOnly).map((tool) => tool.name))
|
|
291
281
|
const params: TurnParams = {
|
|
292
282
|
runId,
|
|
283
|
+
restrictTools: input.run.relation === 'subagent',
|
|
293
284
|
cwd: input.cwd,
|
|
294
285
|
sessionDir: input.sessionDir,
|
|
295
286
|
historyFile,
|
|
@@ -302,7 +293,7 @@ export class Sessions {
|
|
|
302
293
|
automatic: input.automatic,
|
|
303
294
|
account: context,
|
|
304
295
|
resources: registry.resources(root, caller.projectId ?? null, caller.agentId ?? null, capabilities.contributions),
|
|
305
|
-
inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId)],
|
|
296
|
+
inline: [...firstPartyFor(root, capabilities.contributions), runLineageJoin(root, runId), this.options.subagents.extension(runId)],
|
|
306
297
|
...(plugins ? { plugins: { loading: () => plugins.loading({ cwd: input.cwd, agentDir: context.configDir, install: true, caller: { agentId: caller.agentId ?? null, projectId: caller.projectId ?? null }, contributions: capabilities.contributions }), loaded: (extensions) => plugins.loaded(extensions) } } : {}),
|
|
307
298
|
system,
|
|
308
299
|
update
|
|
@@ -310,25 +301,42 @@ export class Sessions {
|
|
|
310
301
|
pi = await this.factory({
|
|
311
302
|
params,
|
|
312
303
|
allow,
|
|
313
|
-
gate:
|
|
304
|
+
gate: (() => {
|
|
305
|
+
const gate = this.gate(run, caller, new Set((input.tools.gated ?? []).filter((name) => capabilities.tools.includes(name))))
|
|
306
|
+
return {
|
|
307
|
+
...gate,
|
|
308
|
+
check: (name: string, args: unknown) => {
|
|
309
|
+
if (input.run.relation === 'subagent' && !capabilities.tools.includes(name)) return Promise.resolve('This tool is outside the child capability snapshot.')
|
|
310
|
+
const refusal = input.plan ? planRefusal(name, readOnly) : null
|
|
311
|
+
return refusal ? Promise.resolve(refusal) : gate.check(name, args)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
})(),
|
|
314
315
|
tools,
|
|
315
316
|
runtime: () => this.options.accounts.runtime(context),
|
|
316
317
|
// An unattended run loads no extension, so it has nobody to relay for.
|
|
317
318
|
...(input.automatic ? {} : { extensions: { ask: (question) => run.requestAnswer(question), notice: (notice) => run.emit('extension.notice', notice) } })
|
|
318
319
|
})
|
|
319
|
-
if (
|
|
320
|
+
if (run.finished) {
|
|
320
321
|
pi.dispose()
|
|
322
|
+
return { runId, nativeSessionId: null }
|
|
323
|
+
}
|
|
324
|
+
if ((input.requiresImageInput || input.images?.length) && !pi.model?.input.includes('image')) {
|
|
321
325
|
throw new HarnessError('image_input_unsupported', 'Choose a model that supports images, or remove the image attachments and send again.')
|
|
322
326
|
}
|
|
323
327
|
if (input.resume?.nativeSessionId && pi.sessionId !== input.resume.nativeSessionId) {
|
|
324
|
-
pi.dispose()
|
|
325
328
|
run.finish('failed', { code: 'native_resume_unavailable', message: 'The saved conversation could not be restored.' })
|
|
326
329
|
throw new HarnessError('native_resume_unavailable', 'The saved conversation could not be restored.')
|
|
327
330
|
}
|
|
328
|
-
|
|
331
|
+
const opened = pi
|
|
332
|
+
updateRun(root, runId, (record) => void (record.nativeSessionId = opened.sessionId))
|
|
333
|
+
this.options.subagents.register(input, account.id, capabilities, Boolean(pi.sessionManager))
|
|
334
|
+
if (pi.model?.id && pi.model.provider) this.options.subagents.resolvedModel(runId, `${pi.model.provider}/${pi.model.id}`, pi.thinkingLevel)
|
|
335
|
+
params.settle = () => this.options.subagents.verify(runId, opened.sessionManager ? subagentHistory(opened.sessionManager.getBranch()) : undefined)
|
|
329
336
|
void runTurn(run, pi, params)
|
|
330
337
|
return { runId, nativeSessionId: pi.sessionId }
|
|
331
338
|
} catch (error) {
|
|
339
|
+
pi?.dispose()
|
|
332
340
|
if (error instanceof HarnessError && error.code === 'native_resume_unavailable') throw error
|
|
333
341
|
const message = error instanceof Error ? error.message : String(error)
|
|
334
342
|
log('session failed to start', message)
|