@xingular/harness 0.1.5 → 0.1.7
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 +11 -0
- package/NOTICE.md +38 -0
- package/README.md +118 -26
- package/dist/cli.js +6106 -912
- package/dist/terminal-pi.js +462 -11
- package/docs/README.md +12 -0
- package/docs/decisions/ADR-0001-owned-terminal.md +87 -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 +130 -0
- 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 +95 -0
- 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 +27 -0
- 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 +18 -0
- package/docs/evidence/terminal-2026-10-17-followup/global-install/verification.json +95 -0
- 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 +60 -0
- package/docs/evidence/terminal-2026-10-17-followup/live-codex/README.md +24 -0
- 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 +57 -0
- 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 +58 -0
- 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 +320 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.json +30 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-comparison.md +133 -0
- package/docs/evidence/terminal-2026-10-17-followup/reference-source-observations.json +20 -0
- package/docs/evidence/terminal-2026-10-17-followup/verification.json +207 -0
- package/docs/terminal-tui.md +254 -0
- package/package.json +8 -2
- package/src/accounts/accounts.test.ts +110 -0
- package/src/accounts/accounts.ts +146 -10
- package/src/cli.ts +7 -3
- package/src/harness.ts +16 -3
- package/src/hub/catalog.json +123 -0
- package/src/hub/cli.ts +59 -0
- package/src/hub/hub.test.ts +205 -0
- package/src/hub/hub.ts +256 -0
- package/src/hub/schema.ts +81 -0
- package/src/index.ts +6 -1
- package/src/memory/api.ts +16 -2
- package/src/memory/blocks.ts +3 -1
- package/src/memory/caller.ts +13 -1
- package/src/memory/curation.test.ts +163 -0
- package/src/memory/curation.ts +64 -0
- package/src/memory/maintenance.ts +199 -11
- package/src/memory/operations.ts +12 -0
- package/src/memory/proposals.ts +16 -8
- package/src/memory/recall.test.ts +11 -0
- package/src/memory/recall.ts +9 -2
- package/src/server/methods.ts +37 -2
- package/src/sessions/contracts.ts +29 -3
- package/src/sessions/conversation.test.ts +27 -0
- package/src/sessions/policy.ts +4 -0
- package/src/sessions/questionBatches.test.ts +110 -0
- package/src/sessions/registry.ts +109 -22
- package/src/sessions/runner.ts +36 -1
- package/src/sessions/sessions.test.ts +95 -1
- package/src/sessions/sessions.ts +53 -9
- package/src/skills/registry.ts +16 -10
- package/src/terminal/accountsMenu.test.ts +225 -0
- package/src/terminal/accountsMenu.ts +382 -0
- package/src/terminal/control.test.ts +23 -0
- package/src/terminal/control.ts +23 -0
- package/src/terminal/controller.ts +68 -4
- package/src/terminal/hubMenu.ts +24 -0
- package/src/terminal/launch.test.ts +5 -14
- package/src/terminal/launch.ts +2 -42
- package/src/terminal/piExtension.lifecycle.test.ts +55 -0
- package/src/terminal/piExtension.ts +50 -1
- package/src/terminal/socket.ts +13 -2
- package/src/terminal/standalone.test.ts +37 -0
- package/src/terminal/standalone.ts +16 -7
- package/src/terminal/terminal.test.ts +70 -1
- package/src/terminal/terminals.ts +122 -12
- package/src/terminal/tui/application.test.ts +114 -0
- package/src/terminal/tui/application.ts +491 -0
- package/src/terminal/tui/commands.test.ts +218 -0
- package/src/terminal/tui/commands.ts +361 -0
- package/src/terminal/tui/dialogs.ts +301 -0
- package/src/terminal/tui/editor.test.ts +84 -0
- package/src/terminal/tui/editor.ts +79 -0
- package/src/terminal/tui/keybindings.test.ts +22 -0
- package/src/terminal/tui/keybindings.ts +50 -0
- package/src/terminal/tui/piAdapter.ts +62 -0
- package/src/terminal/tui/presentation.test.ts +79 -0
- package/src/terminal/tui/questions.test.ts +41 -0
- package/src/terminal/tui/questions.ts +47 -0
- package/src/terminal/tui/runtime.socket.test.ts +104 -0
- package/src/terminal/tui/runtime.test.ts +215 -0
- package/src/terminal/tui/runtime.ts +388 -0
- package/src/terminal/tui/sessionUi.ts +24 -0
- package/src/terminal/tui/settings.test.ts +150 -0
- package/src/terminal/tui/settings.ts +188 -0
- package/src/terminal/tui/status.test.ts +70 -0
- package/src/terminal/tui/status.ts +109 -0
- package/src/terminal/tui/theme.ts +174 -0
- package/src/terminal/tui/tools.test.ts +145 -0
- package/src/terminal/tui/tools.ts +142 -0
- package/src/terminal/tui/transcript.test.ts +25 -0
- package/src/terminal/tui/transcript.ts +124 -0
- package/src/terminal/tui/ui.test.ts +313 -0
- package/src/terminal/tui/ui.ts +166 -0
- package/src/terminal/tui/welcome.ts +102 -0
- package/src/tools/askUser.test.ts +69 -0
- package/src/tools/askUser.ts +31 -17
- package/src/tools/maintenance.ts +24 -0
- package/src/tools/memoryTools.ts +1 -1
- package/src/tools/registryWrite.test.ts +101 -0
- package/src/tools/registryWrite.ts +182 -0
- package/src/tools/spawnSubagent.ts +92 -0
package/src/memory/proposals.ts
CHANGED
|
@@ -95,6 +95,8 @@ export interface Evidence {
|
|
|
95
95
|
sourceText?: string | null
|
|
96
96
|
/** Ids of the blocks attached to the run. */
|
|
97
97
|
blocks?: string[]
|
|
98
|
+
runs?: Record<string, string>
|
|
99
|
+
sources?: Record<string, string>
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
const MIN_EXCERPT = 12
|
|
@@ -175,6 +177,7 @@ export function captureHashes(wiki: string): Map<string, string> {
|
|
|
175
177
|
for (const page of store.loadPages(wiki)) add(page.frontmatter.id, page.path)
|
|
176
178
|
for (const memory of facts.loadMemories(wiki)) add(memory.front.id, memory.path)
|
|
177
179
|
for (const block of blocks.loadAll(wiki)) add(block.id, block.path)
|
|
180
|
+
for (const item of registry.list(dirname(wiki))) add(item.id, item.path)
|
|
178
181
|
return hashes
|
|
179
182
|
}
|
|
180
183
|
|
|
@@ -223,7 +226,11 @@ interface Write {
|
|
|
223
226
|
}
|
|
224
227
|
|
|
225
228
|
/** Validates proposals and applies the valid ones as one operation. Pure file logic. */
|
|
226
|
-
export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence): ApplyOutcome {
|
|
229
|
+
export function apply(wiki: string, defaultNamespace: string, trigger: Trigger, proposals: ProposalSet, captured: ReadonlyMap<string, string>, evidence: Evidence, operationKey?: string, appliedBy?: string): ApplyOutcome {
|
|
230
|
+
if (operationKey) {
|
|
231
|
+
const committed = readJson<Operation>(join(operationsDir(wiki), `${safeId(operationKey)}.json`))
|
|
232
|
+
if (committed) return { operation: committed, parked: 0, flagged: 0, notes: ['This request was already applied.'] }
|
|
233
|
+
}
|
|
227
234
|
const existing = store.loadPages(wiki)
|
|
228
235
|
const existingMemories = facts.loadMemories(wiki)
|
|
229
236
|
const root = dirname(wiki)
|
|
@@ -240,7 +247,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
240
247
|
])
|
|
241
248
|
const outcome: ApplyOutcome = { operation: null, parked: 0, flagged: 0, notes: [] }
|
|
242
249
|
const writes: Write[] = []
|
|
243
|
-
const actor = triggerActor(trigger)
|
|
250
|
+
const actor = appliedBy ?? triggerActor(trigger)
|
|
244
251
|
const described = describeTrigger(trigger)
|
|
245
252
|
const fromUser = trigger.kind === 'user'
|
|
246
253
|
|
|
@@ -248,7 +255,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
248
255
|
const park = (kind: string, title: string, label: string, reason: string, body: string, entryId: string, payload: unknown): void => {
|
|
249
256
|
outcome.parked += 1
|
|
250
257
|
outcome.notes.push(`${label} went to the Inbox: ${reason}`)
|
|
251
|
-
inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, payload)
|
|
258
|
+
inbox(wiki, kind, title, `${reason}\n\n${takeChars(body, 600)}`, [entryId], null, operationKey && payload && typeof payload === 'object' ? { ...payload, curationJobId: operationKey } : payload)
|
|
252
259
|
}
|
|
253
260
|
|
|
254
261
|
// Evidence: a source in the Wiki, the triggering run, a URL, or the user's acceptance. A run
|
|
@@ -259,11 +266,12 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
259
266
|
for (const reference of refs) {
|
|
260
267
|
let id: string | null = null
|
|
261
268
|
if (reference.kind === 'source') {
|
|
262
|
-
|
|
269
|
+
const source = evidence.sources?.[reference.ref] ?? (trigger.kind === 'ingest' && trigger.sourceId === reference.ref ? evidence.sourceText : null)
|
|
270
|
+
if (sourceIds.has(reference.ref) && (operationKey ? quotedIn(source, reference.excerpt) : (evidence.sourceText == null || quotedIn(evidence.sourceText, reference.excerpt)))) id = reference.ref
|
|
263
271
|
} else if (reference.kind === 'run') {
|
|
264
|
-
if (triggerRunId(trigger) === reference.ref
|
|
272
|
+
if (quotedIn(evidence.runs?.[reference.ref] ?? (triggerRunId(trigger) === reference.ref ? evidence.runText : null), reference.excerpt)) id = `run:${reference.ref}`
|
|
265
273
|
} else if (reference.kind === 'url') {
|
|
266
|
-
if (reference.ref.startsWith('https://') || reference.ref.startsWith('http://')) id = `url:${reference.ref}`
|
|
274
|
+
if (!operationKey && (reference.ref.startsWith('https://') || reference.ref.startsWith('http://'))) id = `url:${reference.ref}`
|
|
267
275
|
} else if (reference.kind === 'user') {
|
|
268
276
|
if (fromUser) id = 'user:accepted'
|
|
269
277
|
}
|
|
@@ -277,7 +285,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
277
285
|
.filter((link) => knownIds.has(link.toId) || proposedIds.has(link.toId))
|
|
278
286
|
.filter((link) => link.toId !== ownId)
|
|
279
287
|
.map((link) => ({ fromId: ownId, toId: link.toId, type: link.type }))
|
|
280
|
-
const changedSince = (id: string, path: string): boolean => captured.has(id) && captured.get(id) !== fileHash(path)
|
|
288
|
+
const changedSince = (id: string, path: string): boolean => (captured.has(id) || Boolean(operationKey)) && captured.get(id) !== fileHash(path)
|
|
281
289
|
|
|
282
290
|
for (const proposal of proposals.pages) {
|
|
283
291
|
const pageId = store.slug(proposal.id ?? proposal.title)
|
|
@@ -464,7 +472,7 @@ export function apply(wiki: string, defaultNamespace: string, trigger: Trigger,
|
|
|
464
472
|
}
|
|
465
473
|
})
|
|
466
474
|
|
|
467
|
-
const operationId = newId()
|
|
475
|
+
const operationId = operationKey ?? newId()
|
|
468
476
|
const short = operationId.slice(0, 8)
|
|
469
477
|
const pages: OperationPage[] = writes.map((write) => ({
|
|
470
478
|
pageId: write.entryId,
|
|
@@ -87,6 +87,17 @@ test('an agent recalls its own chats by default and everything on request', () =
|
|
|
87
87
|
expect(searchTool(env, { ...agent('agent-a'), runId: 'chat-1' }, { query: 'pnpm' })).not.toContain('run chat-1')
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
test('a caller cannot recall the conversations of agents its host excludes', () => {
|
|
91
|
+
const env = setup()
|
|
92
|
+
sync(env.root)
|
|
93
|
+
const caller = { ...agent('agent-a'), recallExcludeAgents: ['agent-b'] }
|
|
94
|
+
const all = searchTool(env, caller, { query: 'pnpm', scope: 'all' })
|
|
95
|
+
expect(all).toContain('chat-1')
|
|
96
|
+
expect(all).not.toContain('chat-2')
|
|
97
|
+
expect(() => readTool(env, caller, { run: 'chat-2' })).toThrow()
|
|
98
|
+
expect(readTool(env, agent('agent-a'), { run: 'chat-2' })).toContain('Plan the launch')
|
|
99
|
+
})
|
|
100
|
+
|
|
90
101
|
test('a session recalls only session runs of its project', () => {
|
|
91
102
|
const env = setup()
|
|
92
103
|
sync(env.root)
|
package/src/memory/recall.ts
CHANGED
|
@@ -221,6 +221,13 @@ export function sourceLabel(env: HarnessEnv, mode: string, sessionId: string, pr
|
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/** Whether a conversation belongs to an agent the caller may not recall. */
|
|
225
|
+
function excluded(env: HarnessEnv, context: CallerContext, sessionId: string): boolean {
|
|
226
|
+
const hidden = context.recallExcludeAgents ?? []
|
|
227
|
+
if (!hidden.length) return false
|
|
228
|
+
return hidden.includes(sessionId) || hidden.includes(env.directory.sessionAgent(sessionId) ?? '')
|
|
229
|
+
}
|
|
230
|
+
|
|
224
231
|
/** `recall.search` as text for a model. */
|
|
225
232
|
export function searchTool(env: HarnessEnv, context: CallerContext, params: Params): string {
|
|
226
233
|
const query = textParam(params, 'query')
|
|
@@ -229,7 +236,7 @@ export function searchTool(env: HarnessEnv, context: CallerContext, params: Para
|
|
|
229
236
|
const requested = params?.limit
|
|
230
237
|
const limit = typeof requested === 'number' && Number.isInteger(requested) && requested >= 0 ? requested : SEARCH_DEFAULT
|
|
231
238
|
// The caller's current run is what it is in the middle of, not something to recall.
|
|
232
|
-
const hits = search(env.root, query, scope, limit + 4).filter((hit) => hit.runId !== context.runId).slice(0, Math.min(Math.max(limit, 1), SEARCH_MAX))
|
|
239
|
+
const hits = search(env.root, query, scope, limit + 4 + (context.recallExcludeAgents?.length ? SEARCH_MAX : 0)).filter((hit) => hit.runId !== context.runId && !excluded(env, context, hit.sessionId)).slice(0, Math.min(Math.max(limit, 1), SEARCH_MAX))
|
|
233
240
|
if (!hits.length) {
|
|
234
241
|
return scope.sessionId && !isSession(context) ? 'Nothing in your own conversations matches. Pass scope "all" to search every conversation and Harness run.' : 'Nothing in the recorded history matches.'
|
|
235
242
|
}
|
|
@@ -242,7 +249,7 @@ export function readTool(env: HarnessEnv, context: CallerContext, params: Params
|
|
|
242
249
|
const run = findRun(env.root, runId)
|
|
243
250
|
if (!run) throw notFound('Run')
|
|
244
251
|
// Reading is as wide as an "all" search for an agent, and as narrow as a search for a session.
|
|
245
|
-
if (!mayRead(callerScope(env.root, context, { scope: 'all' }), run)) throw notFound('Run')
|
|
252
|
+
if (!mayRead(callerScope(env.root, context, { scope: 'all' }), run) || excluded(env, context, run.sessionId)) throw notFound('Run')
|
|
246
253
|
const messages = read(env.root, run.id)
|
|
247
254
|
if (!messages.length) return 'That run has no recorded messages.'
|
|
248
255
|
let text = `Run ${run.id} · ${run.startedAt.slice(0, 16)} · ${sourceLabel(env, run.mode, run.sessionId, run.projectId)} · ${run.status}\n`
|
package/src/server/methods.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { capabilitySet } from '../capabilities/runSnapshot'
|
|
|
14
14
|
import type { Harness } from '../harness'
|
|
15
15
|
import * as blocks from '../memory/blocks'
|
|
16
16
|
import { validateScopeIds } from '../mcp/connections'
|
|
17
|
-
import type
|
|
17
|
+
import { callerBlocks, type CallerContext } from '../memory/caller'
|
|
18
18
|
import { dispatch } from '../memory/dispatch'
|
|
19
19
|
import * as facts from '../memory/facts'
|
|
20
20
|
import * as graph from '../memory/graph'
|
|
@@ -46,6 +46,16 @@ const text = (params: Params, name: string): string => {
|
|
|
46
46
|
return value
|
|
47
47
|
}
|
|
48
48
|
const optional = (params: Params, name: string): string | null => (typeof params[name] === 'string' && params[name] ? (params[name] as string) : null)
|
|
49
|
+
const questionAnswer = (value: unknown): { selected: string[]; text?: string; notes?: Record<string, string> } => {
|
|
50
|
+
const answer = value && typeof value === 'object' && !Array.isArray(value) ? value as Params : {}
|
|
51
|
+
return {
|
|
52
|
+
selected: Array.isArray(answer.selected) ? answer.selected.filter((label: unknown): label is string => typeof label === 'string') : [],
|
|
53
|
+
...(typeof answer.text === 'string' && answer.text ? { text: answer.text } : {}),
|
|
54
|
+
...(answer.notes && typeof answer.notes === 'object' && !Array.isArray(answer.notes)
|
|
55
|
+
? { notes: Object.fromEntries(Object.entries(answer.notes).filter((entry): entry is [string, string] => typeof entry[1] === 'string')) }
|
|
56
|
+
: {})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
49
59
|
const pluginScope = (params: Params): plugins.PluginScope => ({ cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') })
|
|
50
60
|
|
|
51
61
|
/** Sources larger than this are not imported. */
|
|
@@ -65,6 +75,14 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
65
75
|
'harness.status': () => ({ dataRoot: harness.dataRoot, productName: env.productName, recovery: harness.recovery, activeRuns: runs.activeRunIds() }),
|
|
66
76
|
'harness.open': (params: Params) => harness.open(text(params, 'dataRoot'), Array.isArray(params.agentIds) ? params.agentIds : []),
|
|
67
77
|
'harness.rebuildIndex': () => harness.rebuildIndex(),
|
|
78
|
+
'maintenance.list': (params: Params) => maintenance.jobs().filter((job) => params.runId ? job.runId === params.runId || (Array.isArray(params.ids) && params.ids.includes(job.id)) : params.includeCompleted === true || !['completed', 'rejected'].includes(job.status)).map(({ prepared: _prepared, hashes: _hashes, ...job }) => job),
|
|
79
|
+
'maintenance.review': (params: Params) => { maintenance.enqueue({ kind: 'review', requestId: optional(params, 'requestId') ?? String(Date.now()), manual: params.manual === true }); return { queued: true } },
|
|
80
|
+
'maintenance.failRun': (params: Params) => { maintenance.finishRun(text(params, 'runId'), true); return null },
|
|
81
|
+
'maintenance.retry': (params: Params) => maintenance.retry(text(params, 'id')),
|
|
82
|
+
'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
|
|
83
|
+
'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
|
|
84
|
+
'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
|
|
85
|
+
'hub.readDocument': (params: Params) => harness.hub.readDocument(text(params, 'id'), text(params, 'version')),
|
|
68
86
|
|
|
69
87
|
// ---- Accounts --------------------------------------------------------------------------------
|
|
70
88
|
'accounts.list': () => accounts.overview(),
|
|
@@ -84,6 +102,7 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
84
102
|
'accounts.sharedDir': () => ensureSharedDir(accounts.dir),
|
|
85
103
|
'accounts.refresh': (params: Params) => accounts.refresh(optional(params, 'accountId')),
|
|
86
104
|
'accounts.loginStart': (params: Params) => accounts.loginStart(text(params, 'accountId'), text(params, 'provider'), optional(params, 'method')),
|
|
105
|
+
'accounts.loginStatus': (params: Params) => accounts.loginStatus(text(params, 'accountId')),
|
|
87
106
|
'accounts.loginRespond': (params: Params) => accounts.loginRespond(text(params, 'loginId'), text(params, 'promptId'), typeof params.value === 'string' ? params.value : null),
|
|
88
107
|
'accounts.loginCancel': (params: Params) => accounts.loginCancel(text(params, 'accountId'), optional(params, 'loginId')),
|
|
89
108
|
'accounts.setApiKey': (params: Params) => accounts.setApiKey(text(params, 'accountId'), text(params, 'key'), text(params, 'provider')),
|
|
@@ -196,6 +215,11 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
196
215
|
},
|
|
197
216
|
|
|
198
217
|
// ---- What a session calls, on behalf of a caller the host names -------------------------------
|
|
218
|
+
'memory.permissions': (params: Params) => {
|
|
219
|
+
const context = (params.context ?? {}) as CallerContext
|
|
220
|
+
const { caller, attached } = callerBlocks(env, context)
|
|
221
|
+
return attached.map((block) => ({ id: block.id, writable: blocks.editRefusal(caller, block) === null, reason: blocks.editRefusal(caller, block) }))
|
|
222
|
+
},
|
|
199
223
|
'memory.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}),
|
|
200
224
|
|
|
201
225
|
// ---- Recall ------------------------------------------------------------------------------------
|
|
@@ -278,6 +302,13 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
278
302
|
await runs.interrupt(runId)
|
|
279
303
|
return loadRun(root(), runId)
|
|
280
304
|
},
|
|
305
|
+
// A message the person sends into a live reply. It reaches the model after the current step;
|
|
306
|
+
// `message.steered` marks the moment, and a message the run never takes is reported by its end.
|
|
307
|
+
'runs.steer': async (params: Params) => {
|
|
308
|
+
const runId = text(params, 'runId')
|
|
309
|
+
await runs.steer(runId, text(params, 'text'))
|
|
310
|
+
return { runId }
|
|
311
|
+
},
|
|
281
312
|
// What the application reports into a live run, and an approval it asks a person for.
|
|
282
313
|
'runs.emit': (params: Params) => {
|
|
283
314
|
const run = runs.active(text(params, 'runId'))
|
|
@@ -298,7 +329,11 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
298
329
|
return { decision }
|
|
299
330
|
},
|
|
300
331
|
'runs.resolveApproval': (params: Params) => runs.resolveApproval(text(params, 'runId'), text(params, 'approvalId'), params.decision),
|
|
301
|
-
'runs.answerQuestion': (params: Params) => runs.answerQuestion(text(params, 'runId'), text(params, 'questionId'),
|
|
332
|
+
'runs.answerQuestion': (params: Params) => runs.answerQuestion(text(params, 'runId'), text(params, 'questionId'), questionAnswer(params.answer)),
|
|
333
|
+
'runs.answerQuestions': (params: Params) => {
|
|
334
|
+
if (!Array.isArray(params.answers)) throw invalid('answers must be an array.')
|
|
335
|
+
runs.answerQuestions(text(params, 'runId'), text(params, 'batchId'), params.answers.map((item: Params) => ({ questionId: text(item, 'questionId'), answer: questionAnswer(item.answer) })))
|
|
336
|
+
},
|
|
302
337
|
|
|
303
338
|
// ---- Sessions ----------------------------------------------------------------------------------
|
|
304
339
|
'sessions.run': (params: Params) => sessions.run(params as SessionRunInput),
|
|
@@ -10,6 +10,7 @@ export const runEventTypeSchema = z.enum([
|
|
|
10
10
|
'context.snapshot',
|
|
11
11
|
'message.delta',
|
|
12
12
|
'message.completed',
|
|
13
|
+
'message.steered',
|
|
13
14
|
'tool.started',
|
|
14
15
|
'tool.completed',
|
|
15
16
|
'retry.started',
|
|
@@ -35,6 +36,8 @@ export type RunEventType = z.infer<typeof runEventTypeSchema>
|
|
|
35
36
|
|
|
36
37
|
const messageDeltaPayload = z.object({ messageId: z.string(), text: z.string() })
|
|
37
38
|
const messageCompletedPayload = z.object({ messageId: z.string(), role: z.enum(['user', 'assistant']), text: z.string() })
|
|
39
|
+
/** A message the person sent into the run while it was going; the model reads it from its next request on. */
|
|
40
|
+
const messageSteeredPayload = z.object({ text: z.string() })
|
|
38
41
|
const contextSnapshotPayload = z.object({
|
|
39
42
|
systemPrompt: z.string().max(100_000),
|
|
40
43
|
contextUpdate: z.string().max(100_000).optional(),
|
|
@@ -96,15 +99,37 @@ const approvalRequestedPayload = z.object({
|
|
|
96
99
|
const approvalResolvedPayload = z.object({ approvalId: z.string(), decision: z.enum(['approved', 'denied', 'native']), automatic: approvalAutomaticSchema.optional() })
|
|
97
100
|
export const questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional() })
|
|
98
101
|
export type QuestionOption = z.infer<typeof questionOptionSchema>
|
|
102
|
+
const questionBatchFields = {
|
|
103
|
+
batchId: z.string().min(1).optional(),
|
|
104
|
+
batchIndex: z.number().int().min(1).max(5).optional(),
|
|
105
|
+
batchCount: z.number().int().min(1).max(5).optional()
|
|
106
|
+
}
|
|
107
|
+
const questionBatchRefinement = <T extends { batchId?: string; batchIndex?: number; batchCount?: number }>(value: T, context: z.RefinementCtx): void => {
|
|
108
|
+
const fields = [value.batchId, value.batchIndex, value.batchCount]
|
|
109
|
+
if (fields.some((field) => field !== undefined) && fields.some((field) => field === undefined)) {
|
|
110
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Question batch fields must be supplied together.' })
|
|
111
|
+
}
|
|
112
|
+
if (value.batchIndex !== undefined && value.batchCount !== undefined && value.batchIndex > value.batchCount) {
|
|
113
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Question batch index cannot exceed its count.' })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
99
116
|
const questionRequestedPayload = z.object({
|
|
100
117
|
questionId: z.string(),
|
|
101
118
|
question: z.string().min(1),
|
|
119
|
+
summary: z.string().min(1).max(100).optional(),
|
|
102
120
|
options: z.array(questionOptionSchema),
|
|
103
121
|
multiSelect: z.boolean(),
|
|
104
122
|
allowText: z.boolean(),
|
|
105
|
-
userOnly: z.boolean().optional()
|
|
106
|
-
|
|
107
|
-
|
|
123
|
+
userOnly: z.boolean().optional(),
|
|
124
|
+
...questionBatchFields
|
|
125
|
+
}).superRefine(questionBatchRefinement)
|
|
126
|
+
const questionAnsweredPayload = z.object({
|
|
127
|
+
questionId: z.string(),
|
|
128
|
+
selected: z.array(z.string()),
|
|
129
|
+
text: z.string().optional(),
|
|
130
|
+
notes: z.record(z.string(), z.string().max(10_000)).optional(),
|
|
131
|
+
...questionBatchFields
|
|
132
|
+
}).superRefine(questionBatchRefinement)
|
|
108
133
|
/** A short emoji sequence, never words or numbers. */
|
|
109
134
|
const reactionAddedPayload = z.object({ emoji: z.string().min(1).refine((text) => Buffer.byteLength(text) <= 16 && !/[A-Za-z0-9\s]/.test(text)) })
|
|
110
135
|
/** A message this run's agent sent to (`to`) or received back from (`from`) another agent. */
|
|
@@ -196,6 +221,7 @@ export const runEventPayloadSchemas = {
|
|
|
196
221
|
'context.snapshot': contextSnapshotPayload,
|
|
197
222
|
'message.delta': messageDeltaPayload,
|
|
198
223
|
'message.completed': messageCompletedPayload,
|
|
224
|
+
'message.steered': messageSteeredPayload,
|
|
199
225
|
'tool.started': toolPayload,
|
|
200
226
|
'tool.completed': toolPayload,
|
|
201
227
|
'retry.started': retryStartedPayload,
|
|
@@ -147,4 +147,31 @@ describe('a whole reply, with a scripted model', () => {
|
|
|
147
147
|
await until(() => settled(id))
|
|
148
148
|
expect(model.lastToolResults().at(-1)).not.toContain('waiting in the Inbox')
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
it('delivers a message sent during a tool call after that call, before the next request', async () => {
|
|
152
|
+
const { harness, model, call, start, until, settled, of } = await setup()
|
|
153
|
+
model.script([model.calls(['bash', { command: 'ls' }]), model.says('Noted.')])
|
|
154
|
+
const id = await start({ tools: { allow: ['read', 'bash'], gated: ['bash'] } })
|
|
155
|
+
await until(() => of(id).some((event) => event.type === 'approval.requested'))
|
|
156
|
+
const approval = of(id).find((event) => event.type === 'approval.requested')!.payload as { approvalId: string }
|
|
157
|
+
await call('runs.steer', { runId: id, text: 'Also check the logs.' })
|
|
158
|
+
harness.runs.resolveApproval(id, approval.approvalId, 'denied')
|
|
159
|
+
await until(() => settled(id))
|
|
160
|
+
const types = of(id).map((event) => event.type)
|
|
161
|
+
expect(of(id).find((event) => event.type === 'message.steered')?.payload).toEqual({ text: 'Also check the logs.' })
|
|
162
|
+
expect(types.indexOf('message.steered')).toBeGreaterThan(types.indexOf('approval.resolved'))
|
|
163
|
+
expect(model.requests()).toBe(2)
|
|
164
|
+
// The context update reaches the model as a user-role message too; the prompt and the steer come last.
|
|
165
|
+
expect(model.lastUserTexts().slice(-2)).toEqual(['Go.', 'Also check the logs.'])
|
|
166
|
+
expect(of(id).at(-1)?.type).toBe('completed')
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('refuses a message once the reply has ended, so the app sends it as a new reply', async () => {
|
|
170
|
+
const { call, model, start, until, settled, of } = await setup()
|
|
171
|
+
model.script([model.says('Done.')])
|
|
172
|
+
const id = await start()
|
|
173
|
+
await until(() => settled(id))
|
|
174
|
+
await expect(call('runs.steer', { runId: id, text: 'Too late.' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
175
|
+
expect(of(id).some((event) => event.type === 'message.steered')).toBe(false)
|
|
176
|
+
})
|
|
150
177
|
})
|
package/src/sessions/policy.ts
CHANGED
|
@@ -17,6 +17,10 @@ export interface ScopeDecision {
|
|
|
17
17
|
allowed: boolean
|
|
18
18
|
/** Not allowed as it stands, but a person may approve this one call. */
|
|
19
19
|
requiresApproval?: boolean
|
|
20
|
+
/** Per-tool remembered approvals never answer this call. */
|
|
21
|
+
humanOnly?: boolean
|
|
22
|
+
/** Whether the global always-approve switch may answer a human-only call. Defaults to false. */
|
|
23
|
+
allowGlobalApproval?: boolean
|
|
20
24
|
reason?: string | null
|
|
21
25
|
}
|
|
22
26
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, test } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { closeIndex } from '../core/index'
|
|
7
|
+
import { runEventPayloadSchemas, runRecordSchema, type RunEvent } from './contracts'
|
|
8
|
+
import { RunRegistry } from './registry'
|
|
9
|
+
import { readEvents, saveRun } from './runStore'
|
|
10
|
+
|
|
11
|
+
const roots: string[] = []
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
for (const root of roots.splice(0)) closeIndex(root)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
function fixture() {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'harness-question-batch-'))
|
|
18
|
+
roots.push(root)
|
|
19
|
+
saveRun(root, runRecordSchema.parse({
|
|
20
|
+
id: 'run-1',
|
|
21
|
+
sessionId: 'session-1',
|
|
22
|
+
projectId: null,
|
|
23
|
+
mode: 'chat',
|
|
24
|
+
harness: 'pi',
|
|
25
|
+
sessionType: 'structured',
|
|
26
|
+
nativeSessionId: null,
|
|
27
|
+
workingDirectory: null,
|
|
28
|
+
worktreeId: null,
|
|
29
|
+
goal: null,
|
|
30
|
+
status: 'running',
|
|
31
|
+
startedAt: 'start',
|
|
32
|
+
endedAt: null
|
|
33
|
+
}))
|
|
34
|
+
const registry = new RunRegistry({ root: () => root })
|
|
35
|
+
const run = registry.start('session-1', 'run-1')
|
|
36
|
+
return { root, registry, run }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('question batches validate all answers before resolving and retain choice notes', async () => {
|
|
40
|
+
const { root, registry, run } = fixture()
|
|
41
|
+
expect(() => run.requestAnswers([])).toThrow(/between one and five/)
|
|
42
|
+
expect(() => run.requestAnswers(Array.from({ length: 6 }, () => ({ question: 'Q', options: [], multiSelect: false, allowText: true })))).toThrow(/between one and five/)
|
|
43
|
+
|
|
44
|
+
const result = run.requestAnswers([
|
|
45
|
+
{ summary: 'Project scope', question: 'What should change?', options: [{ label: 'UI' }, { label: 'API' }], multiSelect: true, allowText: true },
|
|
46
|
+
{ summary: 'Target date', question: 'When should it ship?', options: [{ label: 'Soon' }, { label: 'Later' }], multiSelect: false, allowText: false }
|
|
47
|
+
])
|
|
48
|
+
let settled = false
|
|
49
|
+
void result.then(() => { settled = true })
|
|
50
|
+
|
|
51
|
+
const requested = readEvents(root, 'run-1').filter((event) => event.type === 'question.requested')
|
|
52
|
+
expect(requested).toHaveLength(2)
|
|
53
|
+
const [first, second] = requested.map((event) => event.payload as { questionId: string; summary: string; batchId: string; batchIndex: number; batchCount: number })
|
|
54
|
+
expect(first).toMatchObject({ summary: 'Project scope', batchIndex: 1, batchCount: 2 })
|
|
55
|
+
expect(second).toMatchObject({ summary: 'Target date', batchId: first.batchId, batchIndex: 2, batchCount: 2 })
|
|
56
|
+
expect(() => registry.answerQuestion('run-1', first.questionId, { selected: ['UI'] })).toThrow(/together/)
|
|
57
|
+
|
|
58
|
+
expect(() => registry.answerQuestions('run-1', first.batchId, [
|
|
59
|
+
{ questionId: first.questionId, answer: { selected: ['UI'] } },
|
|
60
|
+
{ questionId: second.questionId, answer: { selected: ['Later'], notes: { Soon: 'wrong choice' } } }
|
|
61
|
+
])).toThrow(/selected choices/)
|
|
62
|
+
await Promise.resolve()
|
|
63
|
+
expect(settled).toBe(false)
|
|
64
|
+
|
|
65
|
+
registry.answerQuestions('run-1', first.batchId, [
|
|
66
|
+
{ questionId: second.questionId, answer: { selected: ['Soon'] } },
|
|
67
|
+
{ questionId: first.questionId, answer: { selected: ['UI'], notes: { UI: 'Keep keyboard navigation.' } } }
|
|
68
|
+
])
|
|
69
|
+
await expect(result).resolves.toEqual([
|
|
70
|
+
{ selected: ['UI'], notes: { UI: 'Keep keyboard navigation.' } },
|
|
71
|
+
{ selected: ['Soon'] }
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
const answered = readEvents(root, 'run-1').filter((event) => event.type === 'question.answered')
|
|
75
|
+
expect(answered).toHaveLength(2)
|
|
76
|
+
expect(answered.find((event) => (event.payload as { questionId: string }).questionId === first.questionId)?.payload).toMatchObject({
|
|
77
|
+
batchId: first.batchId,
|
|
78
|
+
batchIndex: 1,
|
|
79
|
+
batchCount: 2,
|
|
80
|
+
notes: { UI: 'Keep keyboard navigation.' }
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('legacy single questions remain valid alongside batch event metadata', () => {
|
|
85
|
+
const { root, registry, run } = fixture()
|
|
86
|
+
const answer = run.requestAnswer({ question: 'Continue?', options: [{ label: 'Yes' }], multiSelect: false, allowText: false })
|
|
87
|
+
const request = readEvents(root, 'run-1').find((event) => event.type === 'question.requested')!
|
|
88
|
+
expect(runEventPayloadSchemas['question.requested'].safeParse(request.payload).success).toBe(true)
|
|
89
|
+
expect(runEventPayloadSchemas['question.requested'].safeParse({ ...(request.payload as object), batchId: 'partial' }).success).toBe(false)
|
|
90
|
+
registry.answerQuestion('run-1', (request.payload as { questionId: string }).questionId, { selected: ['Yes'] })
|
|
91
|
+
return expect(answer).resolves.toEqual({ selected: ['Yes'] })
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('answer batch method rejects duplicate and foreign question ids without settling the batch', async () => {
|
|
95
|
+
const { root, registry, run } = fixture()
|
|
96
|
+
const result = run.requestAnswers([
|
|
97
|
+
{ summary: 'First', question: 'First?', options: [], multiSelect: false, allowText: true },
|
|
98
|
+
{ summary: 'Second', question: 'Second?', options: [], multiSelect: false, allowText: true }
|
|
99
|
+
])
|
|
100
|
+
const requested = readEvents(root, 'run-1').filter((event) => event.type === 'question.requested')
|
|
101
|
+
const [first, second] = requested.map((event) => event.payload as { questionId: string; batchId: string })
|
|
102
|
+
const answers = [{ questionId: first.questionId, answer: { selected: [], text: 'a' } }, { questionId: second.questionId, answer: { selected: [], text: 'b' } }]
|
|
103
|
+
expect(() => registry.answerQuestions('run-1', 'other-batch', answers)).toThrow(/batch/)
|
|
104
|
+
expect(() => registry.answerQuestions('run-1', first.batchId, [answers[0], answers[0]])).toThrow(/batch/)
|
|
105
|
+
expect(() => registry.answerQuestions('another-run', first.batchId, answers)).toThrow(/batch/)
|
|
106
|
+
const events: RunEvent[] = readEvents(root, 'run-1')
|
|
107
|
+
expect(events.filter((event) => event.type === 'question.answered')).toHaveLength(0)
|
|
108
|
+
registry.answerQuestions('run-1', first.batchId, answers)
|
|
109
|
+
await expect(result).resolves.toHaveLength(2)
|
|
110
|
+
})
|
package/src/sessions/registry.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface ApprovalInput {
|
|
|
27
27
|
export interface QuestionInput {
|
|
28
28
|
requestId?: string
|
|
29
29
|
userOnly?: boolean
|
|
30
|
+
summary?: string
|
|
30
31
|
question: string
|
|
31
32
|
options: QuestionOption[]
|
|
32
33
|
multiSelect: boolean
|
|
@@ -36,6 +37,21 @@ export interface QuestionInput {
|
|
|
36
37
|
export interface QuestionAnswer {
|
|
37
38
|
selected: string[]
|
|
38
39
|
text?: string
|
|
40
|
+
/** Optional notes keyed by the selected choice label. */
|
|
41
|
+
notes?: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface QuestionBatchMetadata {
|
|
45
|
+
batchId: string
|
|
46
|
+
batchIndex: number
|
|
47
|
+
batchCount: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PendingQuestion {
|
|
51
|
+
runId: string
|
|
52
|
+
input: QuestionInput
|
|
53
|
+
batch?: QuestionBatchMetadata
|
|
54
|
+
resolve: (answer: QuestionAnswer) => void
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
export interface ActiveRun {
|
|
@@ -43,10 +59,14 @@ export interface ActiveRun {
|
|
|
43
59
|
sessionId: string
|
|
44
60
|
emit(type: RunEventType, payload: unknown, native?: unknown): void
|
|
45
61
|
onInterrupt(handler: () => Promise<void> | void): void
|
|
62
|
+
/** Hands a message to the model after its current step; the session registers how. */
|
|
63
|
+
onSteer(handler: (text: string) => Promise<void> | void): void
|
|
46
64
|
/** Resolves when the user decides; only the approval with this exact id can be resolved by it. */
|
|
47
65
|
requestApproval(input: ApprovalInput): Promise<ApprovalDecision>
|
|
48
66
|
/** Asks the user a question and resolves with their answer. */
|
|
49
67
|
requestAnswer(input: QuestionInput): Promise<QuestionAnswer>
|
|
68
|
+
/** Asks one to five related questions and resolves only after the whole batch is answered. */
|
|
69
|
+
requestAnswers(inputs: QuestionInput[]): Promise<QuestionAnswer[]>
|
|
50
70
|
finish(outcome: 'completed' | 'failed' | 'interrupted', failure?: { code: string; message: string }): void
|
|
51
71
|
readonly finished: boolean
|
|
52
72
|
}
|
|
@@ -64,12 +84,12 @@ export interface RegistryOptions {
|
|
|
64
84
|
redact?: (event: RunEvent) => void
|
|
65
85
|
}
|
|
66
86
|
|
|
67
|
-
type LiveRun = ActiveRun & { interrupt: () => Promise<void> }
|
|
87
|
+
type LiveRun = ActiveRun & { interrupt: () => Promise<void>; steer: (text: string) => Promise<void> }
|
|
68
88
|
|
|
69
89
|
export class RunRegistry {
|
|
70
90
|
private readonly runs = new Map<string, LiveRun>()
|
|
71
91
|
private readonly approvals = new Map<string, { runId: string; resolve: (decision: ApprovalDecision) => void }>()
|
|
72
|
-
private readonly questions = new Map<string,
|
|
92
|
+
private readonly questions = new Map<string, PendingQuestion>()
|
|
73
93
|
private readonly nativeCleared = new Map<string, string>()
|
|
74
94
|
private readonly stopping = new Set<string>()
|
|
75
95
|
private readonly listeners: RunListener[] = []
|
|
@@ -111,9 +131,32 @@ export class RunRegistry {
|
|
|
111
131
|
}
|
|
112
132
|
let finished = false
|
|
113
133
|
const interruptHandlers: Array<() => Promise<void> | void> = []
|
|
134
|
+
let steerHandler: ((text: string) => Promise<void> | void) | null = null
|
|
114
135
|
const waitingStatus = (): string =>
|
|
115
136
|
[...this.approvals.values()].some((request) => request.runId === runId) ? 'waiting_for_approval' : [...this.questions.values()].some((request) => request.runId === runId) ? 'waiting_for_input' : 'running'
|
|
116
137
|
|
|
138
|
+
const requestQuestion = (input: QuestionInput, batch?: QuestionBatchMetadata, statusEvent = true): Promise<QuestionAnswer> => {
|
|
139
|
+
const { requestId, ...payload } = input
|
|
140
|
+
const questionId = requestId ?? randomUUID()
|
|
141
|
+
if (finished || this.nativeCleared.delete(questionId)) return Promise.resolve({ selected: [] })
|
|
142
|
+
if (this.questions.has(questionId)) throw new HarnessError('invalid_request', 'This question id is already pending.')
|
|
143
|
+
return new Promise<QuestionAnswer>((resolve) => {
|
|
144
|
+
this.questions.set(questionId, {
|
|
145
|
+
runId,
|
|
146
|
+
input,
|
|
147
|
+
...(batch ? { batch } : {}),
|
|
148
|
+
resolve: (answer) => {
|
|
149
|
+
this.questions.delete(questionId)
|
|
150
|
+
emit('question.answered', { questionId, ...answer, ...(batch ?? {}) })
|
|
151
|
+
emit('status.changed', { status: waitingStatus() })
|
|
152
|
+
resolve(answer)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
emit('question.requested', { questionId, ...payload, ...(batch ?? {}) })
|
|
156
|
+
if (statusEvent) emit('status.changed', { status: 'waiting_for_input' })
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
117
160
|
const emit = (type: RunEventType, payload: unknown, native?: unknown): void => {
|
|
118
161
|
if (finished) return
|
|
119
162
|
const parsed = runEventSchema.safeParse({ id: randomUUID(), runId, sessionId, sequence, timestamp: new Date().toISOString(), type, payload, ...(native === undefined ? {} : { native }) })
|
|
@@ -130,6 +173,13 @@ export class RunRegistry {
|
|
|
130
173
|
sessionId,
|
|
131
174
|
emit,
|
|
132
175
|
onInterrupt: (handler) => void interruptHandlers.push(handler),
|
|
176
|
+
onSteer: (handler) => {
|
|
177
|
+
steerHandler = handler
|
|
178
|
+
},
|
|
179
|
+
steer: async (text) => {
|
|
180
|
+
if (finished || !steerHandler) throw new HarnessError('run_not_active', 'This run is not accepting messages.')
|
|
181
|
+
await steerHandler(text)
|
|
182
|
+
},
|
|
133
183
|
requestApproval: async (input) => {
|
|
134
184
|
const { requestId, standing = 'full', timeoutMs, ...payload } = input
|
|
135
185
|
const approvalId = requestId ?? randomUUID()
|
|
@@ -160,24 +210,20 @@ export class RunRegistry {
|
|
|
160
210
|
})
|
|
161
211
|
})
|
|
162
212
|
},
|
|
163
|
-
requestAnswer: (input) =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
213
|
+
requestAnswer: (input) => requestQuestion(input),
|
|
214
|
+
requestAnswers: (inputs) => {
|
|
215
|
+
if (!Array.isArray(inputs) || inputs.length < 1 || inputs.length > 5) {
|
|
216
|
+
throw new HarnessError('invalid_request', 'A question batch must contain between one and five questions.')
|
|
217
|
+
}
|
|
218
|
+
const requestedIds = inputs.flatMap((input) => input.requestId ? [input.requestId] : [])
|
|
219
|
+
if (new Set(requestedIds).size !== requestedIds.length || requestedIds.some((id) => this.questions.has(id))) {
|
|
220
|
+
throw new HarnessError('invalid_request', 'Question ids in a batch must be unique and not already pending.')
|
|
221
|
+
}
|
|
222
|
+
if (finished) return Promise.resolve(inputs.map(() => ({ selected: [] })))
|
|
223
|
+
const batchId = randomUUID()
|
|
224
|
+
const answers = inputs.map((input, index) => requestQuestion(input, { batchId, batchIndex: index + 1, batchCount: inputs.length }, false))
|
|
168
225
|
emit('status.changed', { status: 'waiting_for_input' })
|
|
169
|
-
return
|
|
170
|
-
this.questions.set(questionId, {
|
|
171
|
-
runId,
|
|
172
|
-
input,
|
|
173
|
-
resolve: (answer) => {
|
|
174
|
-
this.questions.delete(questionId)
|
|
175
|
-
emit('question.answered', { questionId, ...answer })
|
|
176
|
-
emit('status.changed', { status: waitingStatus() })
|
|
177
|
-
resolve(answer)
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
})
|
|
226
|
+
return Promise.all(answers)
|
|
181
227
|
},
|
|
182
228
|
finish: (outcome, failure) => {
|
|
183
229
|
if (finished) return
|
|
@@ -243,6 +289,13 @@ export class RunRegistry {
|
|
|
243
289
|
return null
|
|
244
290
|
}
|
|
245
291
|
|
|
292
|
+
/** Hands a message to a live run's model after its current step. Throws `run_not_active` when the run cannot take it. */
|
|
293
|
+
async steer(runId: string, text: string): Promise<void> {
|
|
294
|
+
const run = this.runs.get(runId)
|
|
295
|
+
if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
|
|
296
|
+
await run.steer(text)
|
|
297
|
+
}
|
|
298
|
+
|
|
246
299
|
async interrupt(runId: string): Promise<void> {
|
|
247
300
|
const run = this.runs.get(runId)
|
|
248
301
|
if (!run) throw new HarnessError('run_not_active', 'This run is not active.')
|
|
@@ -272,10 +325,44 @@ export class RunRegistry {
|
|
|
272
325
|
answerQuestion(runId: string, questionId: string, answer: QuestionAnswer): void {
|
|
273
326
|
const question = this.questions.get(questionId)
|
|
274
327
|
if (!question || question.runId !== runId) throw new HarnessError('question_not_pending', 'This question is not waiting for an answer.')
|
|
275
|
-
if (
|
|
276
|
-
|
|
328
|
+
if (question.batch) throw new HarnessError('question_batch_required', 'Answer every question in this batch together.')
|
|
329
|
+
question.resolve(this.validateAnswer(question.input, answer))
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
answerQuestions(runId: string, batchId: string, answers: Array<{ questionId: string; answer: QuestionAnswer }>): void {
|
|
333
|
+
const questions = [...this.questions.entries()].filter(([, pending]) => pending.runId === runId && pending.batch?.batchId === batchId)
|
|
334
|
+
if (!questions.length || answers.length !== questions.length || new Set(answers.map(({ questionId }) => questionId)).size !== answers.length) {
|
|
335
|
+
throw new HarnessError('question_not_pending', 'This question batch is not waiting for an answer.')
|
|
336
|
+
}
|
|
337
|
+
const pendingById = new Map(questions)
|
|
338
|
+
const validated = answers.map(({ questionId, answer }) => {
|
|
339
|
+
const pending = pendingById.get(questionId)
|
|
340
|
+
if (!pending) throw new HarnessError('question_not_pending', 'Every question in the batch must be answered together.')
|
|
341
|
+
return { pending, answer: this.validateAnswer(pending.input, answer) }
|
|
342
|
+
})
|
|
343
|
+
for (const { pending, answer } of validated) pending.resolve(answer)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private validateAnswer(input: QuestionInput, answer: QuestionAnswer): QuestionAnswer {
|
|
347
|
+
const selected = answer.selected
|
|
348
|
+
const selectedLabels = new Set(selected)
|
|
349
|
+
const notes: Record<string, string> = {}
|
|
350
|
+
for (const [label, note] of Object.entries(answer.notes ?? {})) {
|
|
351
|
+
const normalized = typeof note === 'string' ? note.trim() : ''
|
|
352
|
+
if (normalized) notes[label] = normalized
|
|
353
|
+
}
|
|
354
|
+
const text = answer.text?.trim()
|
|
355
|
+
if (
|
|
356
|
+
(!input.multiSelect && selected.length > 1) ||
|
|
357
|
+
selectedLabels.size !== selected.length ||
|
|
358
|
+
selected.some((label) => !input.options.some((option) => option.label === label)) ||
|
|
359
|
+
(text && !input.allowText) ||
|
|
360
|
+
Object.keys(notes).some((label) => !selectedLabels.has(label)) ||
|
|
361
|
+
Object.values(notes).some((note) => note.length > 10_000)
|
|
362
|
+
) {
|
|
363
|
+
throw new HarnessError('invalid_answer', 'Choose the offered options or an allowed text answer, and attach notes only to selected choices.')
|
|
277
364
|
}
|
|
278
|
-
|
|
365
|
+
return { selected, ...(text ? { text } : {}), ...(Object.keys(notes).length ? { notes } : {}) }
|
|
279
366
|
}
|
|
280
367
|
|
|
281
368
|
activeRunIds(): string[] {
|