@xingular/harness 2.1.1 → 3.0.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 +18 -0
- package/NOTICE.md +22 -0
- package/README.md +70 -37
- package/dist/cli.js +6572 -6700
- package/dist/terminal-pi.js +51 -38
- package/docs/terminal-tui.md +50 -12
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +7 -2
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +13 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +60 -13
- package/src/sessions/sessions.ts +43 -18
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +41 -39
- package/src/terminal/terminals.ts +31 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +15 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +3 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
package/src/server/methods.ts
CHANGED
|
@@ -1,42 +1,34 @@
|
|
|
1
1
|
// Everything a host process can ask of the harness, by name. Each method takes one JSON object
|
|
2
2
|
// and returns JSON; the in-process API these call is the same one a Node application uses
|
|
3
3
|
// directly. Methods are grouped by the part of the harness they belong to.
|
|
4
|
-
import { readFileSync, statSync } from 'node:fs'
|
|
5
|
-
import { basename } from 'node:path'
|
|
6
|
-
|
|
7
4
|
import { ensureSharedDir, nativeConfig } from '../accounts/env'
|
|
8
5
|
import { audit, readAudit, summaryAuthentication } from '../accounts/store'
|
|
9
6
|
import { HarnessError, invalid } from '../core/errors'
|
|
10
|
-
import { projectsOrNone } from '../core/projects'
|
|
11
7
|
import * as capabilities from '../capabilities/api'
|
|
12
8
|
import { loadRun as loadCapabilitySnapshot } from '../capabilities/runSnapshot'
|
|
13
9
|
import { capabilitySet } from '../capabilities/runSnapshot'
|
|
14
10
|
import type { Harness } from '../harness'
|
|
15
|
-
import * as blocks from '../memory/blocks'
|
|
16
11
|
import { validateScopeIds } from '../mcp/connections'
|
|
17
|
-
import {
|
|
18
|
-
import
|
|
19
|
-
import * as facts from '../memory/facts'
|
|
20
|
-
import * as graph from '../memory/graph'
|
|
21
|
-
import * as health from '../memory/health'
|
|
22
|
-
import { listInbox, dismissInboxItem } from '../memory/operations'
|
|
23
|
-
import { writeIndex } from '../memory/proposals'
|
|
24
|
-
import * as recall from '../memory/recall'
|
|
25
|
-
import * as references from '../memory/references'
|
|
26
|
-
import { readRunContext } from '../memory/runContext'
|
|
27
|
-
import * as store from '../memory/store'
|
|
28
|
-
import * as wiki from '../memory/wiki'
|
|
29
|
-
import { reindexQuietly, reindexWiki } from '../memory/wikiIndex'
|
|
12
|
+
import type { CallerContext } from '../core/caller'
|
|
13
|
+
import * as recall from '../history/recall'
|
|
30
14
|
import * as plugins from '../plugins/api'
|
|
31
15
|
import * as install from '../plugins/install'
|
|
32
|
-
import {
|
|
16
|
+
import { compile, load, render, type Launch } from '../prompt/compile'
|
|
17
|
+
import { readSummary } from '../refine/runner'
|
|
33
18
|
import { selectHistory } from '../sessions/history'
|
|
34
19
|
import { runsSince } from '../sessions/runIndex'
|
|
35
20
|
import { findRun, listRuns, loadRun, pendingDecisions, readEvents, runFamily, snapshot } from '../sessions/runStore'
|
|
36
21
|
import type { SubagentInput, SubagentSelection } from '../sessions/subagents'
|
|
37
22
|
import type { SessionRunInput } from '../sessions/sessions'
|
|
38
|
-
import * as registry from '../skills/registry'
|
|
39
23
|
import type { TerminalPrepareInput } from '../terminal/terminals'
|
|
24
|
+
import { dispatch } from '../tools/dispatch'
|
|
25
|
+
import * as components from '../tree/components'
|
|
26
|
+
import { materialise, type HarnessChoice } from '../tree/materialise'
|
|
27
|
+
import { seedTree } from '../tree/seed'
|
|
28
|
+
import * as trees from '../tree/store'
|
|
29
|
+
import { PI_SURFACE, type Tree } from '../tree/surface'
|
|
30
|
+
import { applyEdit, autotrigger, TreeError, validate } from '../tree/tree'
|
|
31
|
+
import * as notes from '../wiki/notes'
|
|
40
32
|
import type { Handler } from './rpc'
|
|
41
33
|
|
|
42
34
|
type Params = Record<string, any>
|
|
@@ -59,27 +51,35 @@ const questionAnswer = (value: unknown): { selected: string[]; text?: string; no
|
|
|
59
51
|
}
|
|
60
52
|
const pluginScope = (params: Params): plugins.PluginScope => ({ cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') })
|
|
61
53
|
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
const treeOf = (value: unknown): Tree => {
|
|
55
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid('tree must be an object of path → text.')
|
|
56
|
+
const out: Tree = {}
|
|
57
|
+
for (const [path, body] of Object.entries(value as Record<string, unknown>)) {
|
|
58
|
+
if (typeof body !== 'string') throw invalid(`${path} must be text.`)
|
|
59
|
+
out[path] = body
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Validates a tree without throwing: its census, or every problem. */
|
|
65
|
+
function check(tree: Tree): { ok: true; census: ReturnType<typeof validate> } | { ok: false; problems: string[] } {
|
|
66
|
+
try {
|
|
67
|
+
return { ok: true, census: validate(PI_SURFACE, tree) }
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return { ok: false, problems: error instanceof TreeError ? error.problems : [error instanceof Error ? error.message : String(error)] }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
64
72
|
|
|
65
73
|
export function methods(harness: Harness): Record<string, Handler> {
|
|
66
|
-
const { accounts, approvals, apps, runs, sessions,
|
|
74
|
+
const { accounts, approvals, apps, runs, sessions, terminals, env } = harness
|
|
67
75
|
const root = (): string => env.root
|
|
68
|
-
const
|
|
69
|
-
const changed = (): void => {
|
|
70
|
-
reindexQuietly(env.root)
|
|
71
|
-
harness.bus.emit({ type: 'wiki.changed' })
|
|
72
|
-
}
|
|
76
|
+
const harnessesChanged = (scope: string): void => harness.bus.emit({ type: 'harnesses.changed', scope })
|
|
73
77
|
|
|
74
78
|
return {
|
|
75
79
|
// ---- The harness itself --------------------------------------------------------------------
|
|
76
80
|
'harness.status': () => ({ dataRoot: harness.dataRoot, productName: env.productName, recovery: harness.recovery, activeRuns: runs.activeRunIds() }),
|
|
77
81
|
'harness.open': (params: Params) => harness.open(text(params, 'dataRoot'), Array.isArray(params.agentIds) ? params.agentIds : []),
|
|
78
82
|
'harness.rebuildIndex': () => harness.rebuildIndex(),
|
|
79
|
-
'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),
|
|
80
|
-
'maintenance.review': (params: Params) => { maintenance.enqueue({ kind: 'review', requestId: optional(params, 'requestId') ?? String(Date.now()), manual: params.manual === true }); return { queued: true } },
|
|
81
|
-
'maintenance.failRun': (params: Params) => { maintenance.finishRun(text(params, 'runId'), true); return null },
|
|
82
|
-
'maintenance.retry': (params: Params) => maintenance.retry(text(params, 'id')),
|
|
83
83
|
'hub.list': (params: Params) => harness.hub.list(params.refresh === true),
|
|
84
84
|
'hub.get': (params: Params) => harness.hub.get(text(params, 'id'), text(params, 'version'), params.preview === true),
|
|
85
85
|
'hub.install': (params: Params) => harness.hub.install(text(params, 'id'), text(params, 'version')),
|
|
@@ -140,91 +140,124 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
140
140
|
'connections.appClients': () => apps.appClients(),
|
|
141
141
|
'connections.validateScope': (params: Params) => validateScopeIds(apps.connections.list(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId') }, Array.isArray(params.ids) ? params.ids : []),
|
|
142
142
|
|
|
143
|
-
// ----
|
|
144
|
-
'
|
|
145
|
-
'
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
'
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
143
|
+
// ---- Harness trees: the eight components that shape an agent ---------------------------------
|
|
144
|
+
'harnesses.surface': () => PI_SURFACE,
|
|
145
|
+
'harnesses.list': () => trees.scopes(root()).map((scope) => {
|
|
146
|
+
const head = trees.head(root(), scope)
|
|
147
|
+
return { scope, head, version: head ? trees.versionRecord(root(), scope, head.fingerprint) : null }
|
|
148
|
+
}),
|
|
149
|
+
'harnesses.get': (params: Params) => {
|
|
150
|
+
const scope = text(params, 'scope')
|
|
151
|
+
const head = trees.head(root(), scope)
|
|
152
|
+
const fp = optional(params, 'version') ?? head?.fingerprint ?? null
|
|
153
|
+
if (!fp) return { scope, head: null, version: null, tree: null, plugins: [] }
|
|
154
|
+
const tree = trees.readVersion(root(), scope, fp)
|
|
155
|
+
return { scope, head, version: trees.versionRecord(root(), scope, fp), tree, plugins: harness.trust.review(tree), probeCode: harness.trust.probeCode(scope) }
|
|
155
156
|
},
|
|
156
|
-
|
|
157
|
-
'
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
157
|
+
'harnesses.lineage': (params: Params) => trees.lineage(root(), text(params, 'scope')),
|
|
158
|
+
'harnesses.compare': (params: Params) => trees.compare(root(), text(params, 'scope'), text(params, 'from'), text(params, 'to')),
|
|
159
|
+
'harnesses.validate': (params: Params) => {
|
|
160
|
+
const tree = treeOf(params.tree)
|
|
161
|
+
const repaired = params.repair === true ? autotrigger(PI_SURFACE, tree) : []
|
|
162
|
+
return { ...check(tree), repaired, tree }
|
|
162
163
|
},
|
|
163
|
-
//
|
|
164
|
-
'
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
164
|
+
// A whole tree, or an edit of the head that names only the files it changes (null removes one).
|
|
165
|
+
'harnesses.save': (params: Params) => {
|
|
166
|
+
const scope = text(params, 'scope')
|
|
167
|
+
const base = trees.headTree(root(), scope)?.tree ?? {}
|
|
168
|
+
const tree = params.tree !== undefined ? treeOf(params.tree) : applyEdit(base, (params.edit ?? {}) as Record<string, string | null>)
|
|
169
|
+
if (params.repair === true) autotrigger(PI_SURFACE, tree)
|
|
170
|
+
const record = trees.commit(root(), scope, tree, { kind: params.source === 'import' ? 'import' : 'edit', note: optional(params, 'note') ?? undefined, components: Array.isArray(params.components) ? params.components : undefined })
|
|
171
|
+
harnessesChanged(scope)
|
|
172
|
+
return record
|
|
173
|
+
},
|
|
174
|
+
'harnesses.restore': (params: Params) => {
|
|
175
|
+
const scope = text(params, 'scope')
|
|
176
|
+
const head = trees.restore(root(), scope, text(params, 'version'), optional(params, 'note') ?? undefined)
|
|
177
|
+
harnessesChanged(scope)
|
|
178
|
+
return head
|
|
168
179
|
},
|
|
169
|
-
|
|
170
|
-
'
|
|
180
|
+
// A first version: the tree given, or one built from what shaped the caller before harness trees.
|
|
181
|
+
'harnesses.seed': (params: Params) => {
|
|
182
|
+
const scope = text(params, 'scope')
|
|
183
|
+
const tree = params.tree !== undefined ? treeOf(params.tree) : seedTree(root(), { agentId: optional(params, 'agentId') ?? scope, projectId: optional(params, 'projectId'), instructions: typeof params.instructions === 'string' ? params.instructions : '' })
|
|
184
|
+
const head = trees.seed(root(), scope, tree, optional(params, 'note') ?? undefined)
|
|
185
|
+
harnessesChanged(scope)
|
|
186
|
+
return head
|
|
187
|
+
},
|
|
188
|
+
'harnesses.legacy': (params: Params) => seedTree(root(), { agentId: optional(params, 'agentId'), projectId: optional(params, 'projectId'), instructions: typeof params.instructions === 'string' ? params.instructions : '' }),
|
|
189
|
+
'harnesses.trusted': () => harness.trust.list(),
|
|
190
|
+
'harnesses.trust': (params: Params) => {
|
|
191
|
+
const scope = text(params, 'scope')
|
|
192
|
+
const tree = trees.headTree(root(), scope)?.tree ?? {}
|
|
171
193
|
const path = text(params, 'path')
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
harness.
|
|
175
|
-
|
|
194
|
+
const body = typeof params.text === 'string' ? params.text : tree[path]
|
|
195
|
+
if (typeof body !== 'string') throw invalid('That plugin file is not in the harness.')
|
|
196
|
+
const entry = harness.trust.grant(body, path, scope)
|
|
197
|
+
harnessesChanged(scope)
|
|
198
|
+
return entry
|
|
176
199
|
},
|
|
177
|
-
'
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
'wiki.inbox': () => listInbox(wikiDir()),
|
|
181
|
-
'wiki.inboxDismiss': (params: Params) => {
|
|
182
|
-
dismissInboxItem(wikiDir(), text(params, 'itemId'))
|
|
183
|
-
harness.bus.emit({ type: 'wiki.changed' })
|
|
200
|
+
'harnesses.untrust': (params: Params) => {
|
|
201
|
+
harness.trust.revoke(text(params, 'hash'))
|
|
202
|
+
harness.bus.emit({ type: 'harnesses.changed' })
|
|
184
203
|
},
|
|
185
|
-
'
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
204
|
+
'harnesses.setProbeCode': (params: Params) => {
|
|
205
|
+
const scope = text(params, 'scope')
|
|
206
|
+
harness.trust.setProbeCode(scope, params.on === true)
|
|
207
|
+
harnessesChanged(scope)
|
|
208
|
+
},
|
|
209
|
+
// The component library (skills, rules and prompts kept as files), and adding one to a tree.
|
|
210
|
+
'harnesses.components': () => components.list(root()),
|
|
211
|
+
'harnesses.importComponents': (params: Params) => components.importItems(root(), (optional(params, 'kind') ?? 'skills') as components.ComponentKind, Array.isArray(params.items) ? params.items : []),
|
|
212
|
+
'harnesses.addComponent': (params: Params) => {
|
|
213
|
+
const scope = text(params, 'scope')
|
|
214
|
+
const base = params.tree !== undefined ? treeOf(params.tree) : trees.headTree(root(), scope)?.tree ?? {}
|
|
215
|
+
const tree = components.addToTree(root(), base, text(params, 'id'))
|
|
216
|
+
return { tree, ...check(tree) }
|
|
217
|
+
},
|
|
218
|
+
'harnesses.materialise': (params: Params) => {
|
|
219
|
+
const built = materialise(root(), params.harness as HarnessChoice, { trust: harness.trust })
|
|
220
|
+
return { ...built, tree: undefined }
|
|
191
221
|
},
|
|
192
|
-
'wiki.undoOperation': (params: Params) => maintenance.undoOperation(text(params, 'operationId')),
|
|
193
|
-
'wiki.runContext': (params: Params) => readRunContext(root(), text(params, 'runId')),
|
|
194
222
|
|
|
195
|
-
// ----
|
|
196
|
-
'
|
|
197
|
-
'
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
// ---- Refinement: improving a harness from evidence -------------------------------------------
|
|
224
|
+
'refine.start': (params: Params) => harness.refine.start({ task: params.task, k: typeof params.k === 'number' ? params.k : 1, mode: params.mode === 'sequential' ? 'sequential' : 'parallel', improver: params.improver ?? {}, baseVersion: optional(params, 'baseVersion'), id: optional(params, 'id') ?? undefined }),
|
|
225
|
+
'refine.get': (params: Params) => harness.refine.get(text(params, 'id')),
|
|
226
|
+
'refine.list': (params: Params) => harness.refine.list({ scope: optional(params, 'scope'), limit: typeof params.limit === 'number' ? params.limit : undefined }),
|
|
227
|
+
'refine.running': () => harness.refine.running(),
|
|
228
|
+
'refine.cancel': async (params: Params) => {
|
|
229
|
+
await harness.refine.cancel(text(params, 'id'))
|
|
230
|
+
return readSummary(root(), text(params, 'id'))
|
|
201
231
|
},
|
|
202
|
-
'
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
232
|
+
'refine.candidates': (params: Params) => harness.refine.candidates(text(params, 'id')),
|
|
233
|
+
'refine.adopt': (params: Params) => harness.refine.adopt(text(params, 'id'), { label: optional(params, 'label') }),
|
|
234
|
+
'library.list': (params: Params) => harness.refine.library(text(params, 'scope')),
|
|
235
|
+
'library.read': (params: Params) => harness.refine.libraryEntry(text(params, 'scope'), text(params, 'area'), text(params, 'task')),
|
|
236
|
+
'library.forget': (params: Params) => harness.refine.forget(text(params, 'scope'), text(params, 'area'), Array.isArray(params.tasks) ? params.tasks : []),
|
|
237
|
+
|
|
238
|
+
// ---- The Wiki: project notes ------------------------------------------------------------------
|
|
239
|
+
'wiki.list': (params: Params) => notes.list(root(), params.project === undefined ? {} : { project: optional(params, 'project') }),
|
|
240
|
+
'wiki.read': (params: Params) => notes.read(root(), text(params, 'path')),
|
|
241
|
+
'wiki.write': (params: Params) => {
|
|
242
|
+
const note = notes.write(root(), text(params, 'path'), typeof params.text === 'string' ? params.text : '', { expectedUpdatedAt: optional(params, 'expectedUpdatedAt') })
|
|
243
|
+
harness.bus.emit({ type: 'wiki.changed' })
|
|
244
|
+
return note
|
|
209
245
|
},
|
|
210
|
-
'
|
|
211
|
-
|
|
246
|
+
'wiki.remove': (params: Params) => {
|
|
247
|
+
notes.remove(root(), text(params, 'path'))
|
|
212
248
|
harness.bus.emit({ type: 'wiki.changed' })
|
|
213
|
-
return saved
|
|
214
249
|
},
|
|
215
|
-
'
|
|
216
|
-
|
|
250
|
+
'wiki.search': (params: Params) => notes.search(root(), typeof params.query === 'string' ? params.query : '', { project: optional(params, 'project'), limit: typeof params.limit === 'number' ? params.limit : undefined }),
|
|
251
|
+
'wiki.graph': () => notes.graph(root()),
|
|
252
|
+
'wiki.versions': (params: Params) => notes.versions(root(), text(params, 'path')),
|
|
253
|
+
'wiki.version': (params: Params) => notes.version(root(), text(params, 'path'), text(params, 'id')),
|
|
254
|
+
'wiki.ensureProject': () => {
|
|
255
|
+
notes.ensureLayout(root(), env.productName)
|
|
217
256
|
harness.bus.emit({ type: 'wiki.changed' })
|
|
218
|
-
return block
|
|
219
257
|
},
|
|
220
258
|
|
|
221
259
|
// ---- What a session calls, on behalf of a caller the host names -------------------------------
|
|
222
|
-
'
|
|
223
|
-
const context = (params.context ?? {}) as CallerContext
|
|
224
|
-
const { caller, attached } = callerBlocks(env, context)
|
|
225
|
-
return attached.map((block) => ({ id: block.id, writable: blocks.editRefusal(caller, block) === null, reason: blocks.editRefusal(caller, block) }))
|
|
226
|
-
},
|
|
227
|
-
'memory.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}),
|
|
260
|
+
'harness.call': (params: Params) => dispatch(env, (params.context ?? {}) as CallerContext, text(params, 'call'), params.params ?? {}, { treeDir: optional(params, 'treeDir') }),
|
|
228
261
|
|
|
229
262
|
// ---- Recall ------------------------------------------------------------------------------------
|
|
230
263
|
'recall.search': (params: Params) => recall.searchAll(env, typeof params.query === 'string' ? params.query : '', params.filter ?? {}),
|
|
@@ -232,30 +265,11 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
232
265
|
|
|
233
266
|
// ---- The compiled prompt -----------------------------------------------------------------------
|
|
234
267
|
'context.compile': (params: Params) => {
|
|
235
|
-
const
|
|
268
|
+
const tree = params.harness ? materialise(root(), params.harness as HarnessChoice, { trust: harness.trust }) : null
|
|
269
|
+
const pack = compile(env, (params.context ?? {}) as CallerContext, (params.launch ?? {}) as Launch, tree)
|
|
236
270
|
return params.render ? { pack, text: render(env, pack) } : pack
|
|
237
271
|
},
|
|
238
272
|
'context.runPack': (params: Params) => load(root(), text(params, 'runId')),
|
|
239
|
-
'context.profiles': () => ({ profiles: profiles(root()), stats: comparison(root()) }),
|
|
240
|
-
'context.setProfile': (params: Params) => {
|
|
241
|
-
const profile = params.profile as ContextProfile
|
|
242
|
-
if (!CONTEXT_PROFILES.includes(profile)) throw invalid('Unknown context profile.')
|
|
243
|
-
setProfile(root(), text(params, 'subject'), profile)
|
|
244
|
-
},
|
|
245
|
-
'references.pin': (params: Params) => references.pinAll(root(), (Array.isArray(params.items) ? params.items : []).map((item: Params) => ({ kind: item.kind === 'skill' ? ('skill' as const) : ('page' as const), id: text(item, 'id') }))),
|
|
246
|
-
'references.body': (params: Params) => references.body(root(), text(params, 'id'), optional(params, 'revision')),
|
|
247
|
-
|
|
248
|
-
// ---- Skills, prompts, rules --------------------------------------------------------------------
|
|
249
|
-
'skills.list': () => registry.state(root()),
|
|
250
|
-
'skills.setEnabled': (params: Params) => {
|
|
251
|
-
const state = registry.setEnabled(root(), optional(params, 'projectId'), Array.isArray(params.ids) ? params.ids : [])
|
|
252
|
-
// The same choices are what the plugin list shows.
|
|
253
|
-
harness.bus.emit({ type: 'plugins.changed' })
|
|
254
|
-
return state
|
|
255
|
-
},
|
|
256
|
-
'skills.create': (params: Params) => registry.create(root(), text(params, 'kind'), text(params, 'name')),
|
|
257
|
-
'skills.import': (params: Params) => registry.importSkills(root(), Array.isArray(params.skills) ? params.skills : []),
|
|
258
|
-
'skills.enabledFor': (params: Params) => registry.enabledFor(root(), optional(params, 'projectId')),
|
|
259
273
|
|
|
260
274
|
// ---- Plugins: everything that shapes a session, and the trust a working folder's code needs ---
|
|
261
275
|
// A host that is not a Node program describes its own parts here instead of in the options.
|
|
@@ -273,7 +287,7 @@ export function methods(harness: Harness): Record<string, Handler> {
|
|
|
273
287
|
'plugins.update': (params: Params) => install.update(harness, { ...pluginScope(params), pluginId: optional(params, 'pluginId') }),
|
|
274
288
|
'plugins.configure': (params: Params) => plugins.configure(harness, { ...pluginScope(params), pluginId: text(params, 'pluginId'), settings: (params.settings && typeof params.settings === 'object' ? params.settings : {}) as Record<string, unknown> }),
|
|
275
289
|
'plugins.forgetAgent': (params: Params) => plugins.forgetAgent(harness, text(params, 'agentId')),
|
|
276
|
-
'plugins.inventory': (params: Params) => plugins.inventoryFor(harness, { ...pluginScope(params), context: (params.context ?? {}) as CallerContext, launch: (params.launch ?? {}) as Launch, tools: params.tools, connections: params.connections }),
|
|
290
|
+
'plugins.inventory': (params: Params) => plugins.inventoryFor(harness, { ...pluginScope(params), context: (params.context ?? {}) as CallerContext, launch: (params.launch ?? {}) as Launch, tools: params.tools, connections: params.connections, harness: (params.harness ?? null) as HarnessChoice | null }),
|
|
277
291
|
'plugins.trust': (params: Params) => plugins.trust(harness, { cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), hash: text(params, 'hash') }),
|
|
278
292
|
'plugins.untrust': (params: Params) => plugins.untrust(harness, { cwd: optional(params, 'cwd'), accountId: optional(params, 'accountId'), hash: text(params, 'hash') }),
|
|
279
293
|
|
|
@@ -84,7 +84,7 @@ describe('the harness served to a host', () => {
|
|
|
84
84
|
|
|
85
85
|
const status = await request('harness.status')
|
|
86
86
|
expect(status).toMatchObject({ dataRoot: harness.env.root, productName: 'Xingular Harness', activeRuns: [] })
|
|
87
|
-
expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'sessions.spawnSubagent', 'subagents.spawn', 'subagents.wait', 'subagents.results', 'wiki.
|
|
87
|
+
expect(await request<string[]>('harness.methods')).toEqual(expect.arrayContaining(['accounts.list', 'sessions.run', 'sessions.spawnSubagent', 'subagents.spawn', 'subagents.wait', 'subagents.results', 'wiki.list', 'wiki.write', 'terminal.prepare', 'harnesses.seed', 'harnesses.save', 'refine.start', 'refine.adopt', 'library.list', 'connections.list', 'harness.shutdown']))
|
|
88
88
|
|
|
89
89
|
await expect(request('sessions.spawnSubagent', { parentRunId: 'missing', brief: 'Review' })).rejects.toMatchObject({ code: 'run_not_active' })
|
|
90
90
|
|
|
@@ -95,14 +95,11 @@ describe('the harness served to a host', () => {
|
|
|
95
95
|
await expect(request('accounts.rename', {})).rejects.toMatchObject({ code: 'invalid_request', message: 'accountId is required.' })
|
|
96
96
|
await expect(request('nope.nothing')).rejects.toMatchObject({ code: 'unknown_method' })
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
const blocks = await request<any[]>('blocks.list', { agentIds: ['a1'] })
|
|
100
|
-
expect(blocks.map((block) => block.label)).toEqual(expect.arrayContaining(['user', 'persona', 'notes']))
|
|
101
|
-
expect(await request('memory.call', { context: { agentId: 'a1', agentName: 'Helper', actor: 'Chat' }, call: 'memory.append', params: { block: 'notes', text: 'Ship on Fridays.' } })).toContain('notes')
|
|
98
|
+
await request('wiki.write', { path: 'projects/app/decisions', text: '# Decisions\n\nShip on Fridays.\n' })
|
|
102
99
|
expect(notices).toContainEqual({ type: 'wiki.changed' })
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
expect(
|
|
100
|
+
expect(await request('harness.call', { context: { agentId: 'a1', agentName: 'Helper', actor: 'Chat', projectId: 'p1' }, call: 'wiki.search', params: { query: 'Fridays' } })).toContain('projects/app/decisions')
|
|
101
|
+
const compiled = await request('context.compile', { context: { agentId: 'a1', agentName: 'Helper', actor: 'Chat', projectId: 'p1' }, render: true })
|
|
102
|
+
expect(compiled.text).toContain('projects/app/decisions — Decisions')
|
|
106
103
|
|
|
107
104
|
const started = await request('sessions.run', { run: { id: 'r1', sessionId: 'a1' }, cwd: harness.env.root, sessionDir: join(harness.env.root, 'sessions', 'a1'), text: 'hello', caller: { agentId: 'a1', agentName: 'Helper', actor: 'Chat' }, tools: { allow: ['read'] } })
|
|
108
105
|
expect(started).toEqual({ runId: 'r1', nativeSessionId: 'native-1' })
|
|
@@ -110,7 +107,7 @@ describe('the harness served to a host', () => {
|
|
|
110
107
|
expect(events.map((event) => event.type)).toEqual(['started', 'status.changed', 'context.snapshot', 'completed'])
|
|
111
108
|
expect((await request('runs.load', { runId: 'r1' })).run).toMatchObject({ id: 'r1', status: 'completed' })
|
|
112
109
|
expect((await request<any[]>('runs.list', { filter: { sessionId: 'a1' } })).map((run) => run.id)).toEqual(['r1'])
|
|
113
|
-
expect((await request('context.
|
|
110
|
+
expect((await request('context.runPack', { runId: 'r1' })).sections.map((section: { id: string }) => section.id)).toContain('tools')
|
|
114
111
|
})
|
|
115
112
|
|
|
116
113
|
it('lets a host that is not a Node program supply secrets, policy, names, and tools over the protocol', async () => {
|
|
@@ -163,31 +160,41 @@ describe('the harness served to a host', () => {
|
|
|
163
160
|
})
|
|
164
161
|
})
|
|
165
162
|
|
|
166
|
-
describe('
|
|
167
|
-
it('
|
|
163
|
+
describe('harness trees over the wire', () => {
|
|
164
|
+
it('seeds, edits, validates, restores, and adds a built-in component, then compiles the tree', async () => {
|
|
168
165
|
const builtins = mkdtempSync(join(tmpdir(), 'builtin-'))
|
|
169
|
-
writeFile(join(builtins, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription:
|
|
170
|
-
writeFile(join(builtins, 'rules', 'plain.md'), '---\nname: Plain words\
|
|
171
|
-
const { transport, request } = host()
|
|
166
|
+
writeFile(join(builtins, 'skills', 'review', 'SKILL.md'), '---\nname: review\ndescription: reviewing a change before it ships\n---\nRead the diff first.\n')
|
|
167
|
+
writeFile(join(builtins, 'rules', 'plain.md'), '---\nname: Plain words\n---\nUse plain words.\n')
|
|
168
|
+
const { transport, request, notices } = host()
|
|
172
169
|
const harness = createHarness({ dataRoot: rootWithProject(), stateDir: mkdtempSync(join(tmpdir(), 'hs-')), secrets: memorySecretStore(), builtins: [builtins] })
|
|
173
170
|
open.push(harness)
|
|
174
171
|
serve(harness, transport)
|
|
175
172
|
|
|
176
|
-
const
|
|
177
|
-
expect(
|
|
178
|
-
expect(
|
|
179
|
-
|
|
180
|
-
expect(
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
expect(
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
expect(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
expect(
|
|
173
|
+
const seeded = await request('harnesses.seed', { scope: 'a1', instructions: 'You help with releases.' })
|
|
174
|
+
expect(seeded.fingerprint).toMatch(/^[0-9a-f]{16}$/)
|
|
175
|
+
expect(notices).toContainEqual({ type: 'harnesses.changed', scope: 'a1' })
|
|
176
|
+
const first = await request('harnesses.get', { scope: 'a1' })
|
|
177
|
+
expect(first.tree['systemprompt.md']).toBe('You help with releases.\n')
|
|
178
|
+
|
|
179
|
+
const library = await request<any[]>('harnesses.components')
|
|
180
|
+
expect(library.map((item) => item.id)).toEqual(['rules/plain', 'skills/review'])
|
|
181
|
+
const added = await request('harnesses.addComponent', { scope: 'a1', id: 'skills/review' })
|
|
182
|
+
expect(added.ok).toBe(true)
|
|
183
|
+
expect(added.tree['guardrails.md']).toContain('load the `review` skill')
|
|
184
|
+
const saved = await request('harnesses.save', { scope: 'a1', tree: added.tree, note: 'Added review' })
|
|
185
|
+
expect(saved.parent).toBe(seeded.fingerprint)
|
|
186
|
+
|
|
187
|
+
const bad = await request('harnesses.validate', { tree: { ...added.tree, 'plugins/x.ts': 'nothing' } })
|
|
188
|
+
expect(bad).toMatchObject({ ok: false })
|
|
189
|
+
expect(bad.problems.join(' ')).toContain('default export')
|
|
190
|
+
await expect(request('harnesses.save', { scope: 'a1', edit: { 'plugins/x.ts': 'nothing' } })).rejects.toMatchObject({ code: 'harness_invalid' })
|
|
191
|
+
|
|
192
|
+
const edited = await request('harnesses.save', { scope: 'a1', edit: { 'memory.md': 'Releases ship on Tuesdays.\n' } })
|
|
193
|
+
expect((await request<any[]>('harnesses.lineage', { scope: 'a1' })).map((version) => version.fingerprint)).toEqual([edited.fingerprint, saved.fingerprint, seeded.fingerprint])
|
|
194
|
+
const compiled = await request('context.compile', { context: { agentId: 'a1', actor: 'Chat' }, harness: { scope: 'a1' }, render: true })
|
|
195
|
+
expect(compiled.text).toContain('Releases ship on Tuesdays.')
|
|
196
|
+
await request('harnesses.restore', { scope: 'a1', version: seeded.fingerprint })
|
|
197
|
+
expect((await request('harnesses.get', { scope: 'a1' })).head.fingerprint).toBe(seeded.fingerprint)
|
|
198
|
+
expect(await request('refine.list', { scope: 'a1' })).toEqual([])
|
|
192
199
|
})
|
|
193
200
|
})
|
|
@@ -97,7 +97,7 @@ const approvalRequestedPayload = z.object({
|
|
|
97
97
|
automatic: approvalAutomaticSchema.optional()
|
|
98
98
|
})
|
|
99
99
|
const approvalResolvedPayload = z.object({ approvalId: z.string(), decision: z.enum(['approved', 'denied', 'native']), automatic: approvalAutomaticSchema.optional() })
|
|
100
|
-
export const questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional() })
|
|
100
|
+
export const questionOptionSchema = z.object({ label: z.string().min(1), description: z.string().optional(), recommended: z.boolean().optional() })
|
|
101
101
|
export type QuestionOption = z.infer<typeof questionOptionSchema>
|
|
102
102
|
const questionBatchFields = {
|
|
103
103
|
batchId: z.string().min(1).optional(),
|
|
@@ -299,6 +299,9 @@ export interface RunRecord {
|
|
|
299
299
|
agentId: string | null
|
|
300
300
|
/** What started the run. */
|
|
301
301
|
trigger: string | null
|
|
302
|
+
/** The harness tree the run used: its scope and version fingerprint. */
|
|
303
|
+
harnessScope?: string | null
|
|
304
|
+
harnessVersion?: string | null
|
|
302
305
|
}
|
|
303
306
|
|
|
304
307
|
const text = z.string().nullish().transform((value) => value ?? null)
|
|
@@ -326,7 +329,9 @@ export const runRecordSchema = z.object({
|
|
|
326
329
|
handoffId: text,
|
|
327
330
|
accountId: text,
|
|
328
331
|
agentId: text,
|
|
329
|
-
trigger: text
|
|
332
|
+
trigger: text,
|
|
333
|
+
harnessScope: text.optional(),
|
|
334
|
+
harnessVersion: text.optional()
|
|
330
335
|
}) satisfies z.ZodType<RunRecord, unknown>
|
|
331
336
|
|
|
332
337
|
export const ACTIVE_STATUSES: readonly string[] = ['queued', 'running', 'waiting_for_approval', 'waiting_for_input']
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
// Whole replies, through everything the harness does for one: the compiled prompt, the plugins
|
|
2
2
|
// it chose to load, the gate, the run's events. Pi is real and so are the extensions; only the
|
|
3
3
|
// model is scripted, so no request leaves the machine.
|
|
4
|
-
import {
|
|
4
|
+
import { mkdtempSync } from 'node:fs'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
8
8
|
|
|
9
9
|
import { memorySecretStore } from '../accounts/secrets'
|
|
10
10
|
import { createHarness, type Harness } from '../harness'
|
|
11
|
-
import { listInbox } from '../memory/operations'
|
|
12
|
-
import { wikiDir } from '../memory/store'
|
|
13
11
|
import type { Plugin } from '../plugins/types'
|
|
14
12
|
import { methods } from '../server/methods'
|
|
15
13
|
import { fauxModel } from '../../tests/faux'
|
|
@@ -33,7 +31,7 @@ export default function (pi) {
|
|
|
33
31
|
async function setup() {
|
|
34
32
|
const root = rootWithProject()
|
|
35
33
|
const base = mkdtempSync(join(tmpdir(), 'conversation-'))
|
|
36
|
-
const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore()
|
|
34
|
+
const harness = createHarness({ dataRoot: root, stateDir: join(base, 'state'), secrets: memorySecretStore() })
|
|
37
35
|
open.push(harness)
|
|
38
36
|
harness.accounts.create('Main', 'apiKey')
|
|
39
37
|
const model = await fauxModel(harness)
|
|
@@ -181,29 +179,6 @@ describe('a whole reply, with a scripted model', () => {
|
|
|
181
179
|
expect(model.lastToolResults().at(-1)).toMatch(/denied/)
|
|
182
180
|
})
|
|
183
181
|
|
|
184
|
-
it('lets the agent propose a plugin, which reaches the Inbox and nothing else', async () => {
|
|
185
|
-
const { root, model, start, until, settled, of } = await setup()
|
|
186
|
-
model.script([
|
|
187
|
-
model.calls(['plugin_propose', { name: 'Memory to Docs', description: 'Copies each saved memory to the team document.', extends: ['memory'] as never, files: [{ path: 'hooks.mjs', content: "export default function (harness) { harness.on('memory.saved', async () => {}) }\n" }] as never }]),
|
|
188
|
-
model.says('I proposed it; it is in your Inbox.')
|
|
189
|
-
])
|
|
190
|
-
const id = await start()
|
|
191
|
-
await until(() => settled(id))
|
|
192
|
-
expect(model.lastToolResults().at(-1)).toContain('The plugin "memory-to-docs" is waiting in the Inbox')
|
|
193
|
-
expect(listInbox(wikiDir(root)).map((item) => item.title)).toEqual(['Proposed plugin "Memory to Docs"'])
|
|
194
|
-
expect(existsSync(join(root, 'plugins'))).toBe(false)
|
|
195
|
-
// Proposing is not something a person is asked to approve: it changes nothing until they accept.
|
|
196
|
-
expect(of(id).some((event) => event.type === 'approval.requested')).toBe(false)
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
it('has no plugin proposal tool in unattended work', async () => {
|
|
200
|
-
const { model, start, until, settled } = await setup()
|
|
201
|
-
model.script([model.calls(['plugin_propose', { name: 'x', description: 'y', files: [] as never }]), model.says('Done.')])
|
|
202
|
-
const id = await start({ automatic: true })
|
|
203
|
-
await until(() => settled(id))
|
|
204
|
-
expect(model.lastToolResults().at(-1)).not.toContain('waiting in the Inbox')
|
|
205
|
-
})
|
|
206
|
-
|
|
207
182
|
it('delivers a message sent during a tool call after that call, before the next request', async () => {
|
|
208
183
|
const { harness, model, call, start, until, settled, of } = await setup()
|
|
209
184
|
model.script([model.calls(['bash', { command: 'ls' }]), model.says('Noted.')])
|
|
@@ -26,10 +26,17 @@ describe('plan mode', () => {
|
|
|
26
26
|
const readOnly = new Set(['files_read'])
|
|
27
27
|
for (const tool of ['read', 'grep', 'wiki_read', 'files_read', 'ask_user']) expect(planRefusal(tool, readOnly), tool).toBeNull()
|
|
28
28
|
for (const tool of ['bash', 'write', 'edit', 'memory_append', 'wiki_propose', 'spawn_subagent', 'mcp_call_tool', 'files_write']) {
|
|
29
|
-
expect(planRefusal(tool, readOnly), tool).toContain('Plan mode is
|
|
29
|
+
expect(planRefusal(tool, readOnly), tool).toContain('Plan mode is research-only')
|
|
30
30
|
}
|
|
31
31
|
})
|
|
32
32
|
|
|
33
|
+
it('allows only provider calls whose arguments are explicitly classified as research-safe', () => {
|
|
34
|
+
const safeBrowserCall = (input: Record<string, unknown>) => input.op === 'navigate' || input.op === 'scroll'
|
|
35
|
+
expect(planRefusal('browser', new Set(), { op: 'navigate' }, safeBrowserCall)).toBeNull()
|
|
36
|
+
expect(planRefusal('browser', new Set(), { op: 'click' }, safeBrowserCall)).toContain('research-only')
|
|
37
|
+
expect(planRefusal('browser', new Set(), { op: 'submit' }, safeBrowserCall)).toContain('research-only')
|
|
38
|
+
})
|
|
39
|
+
|
|
33
40
|
it('adds its instructions while on, withdraws them once after, and is absent otherwise', () => {
|
|
34
41
|
expect(planSection(true, null)).toMatchObject({ id: PLAN_SECTION_ID, body: PLAN_INSTRUCTIONS, dynamic: true })
|
|
35
42
|
expect(PLAN_INSTRUCTIONS).toContain('<proposed_plan>')
|