@xingular/harness 2.1.2 → 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 +12 -0
- package/NOTICE.md +22 -0
- package/README.md +67 -36
- package/dist/cli.js +6023 -6621
- package/dist/terminal-pi.js +19 -24
- package/docs/terminal-tui.md +27 -7
- 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/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 +6 -1
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/registry.ts +5 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +45 -6
- package/src/sessions/sessions.ts +40 -17
- 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/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +21 -39
- package/src/terminal/terminals.ts +20 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +10 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/runtime.ts +2 -0
- 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 +1 -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/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/hub/hub.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
6
6
|
import { Bus } from '../core/bus'
|
|
7
7
|
import { Harness } from '../harness'
|
|
8
8
|
import { memorySecretStore } from '../accounts/secrets'
|
|
9
|
-
import * as registry from '../
|
|
9
|
+
import * as registry from '../tree/components'
|
|
10
10
|
import { Hub } from './hub'
|
|
11
11
|
import { hubCatalogSchema, hubEntrySchema, type HubEntry } from './schema'
|
|
12
12
|
import { runHubMenu } from '../terminal/hubMenu'
|
|
@@ -47,7 +47,7 @@ describe('Hub installation', () => {
|
|
|
47
47
|
const { hub, root } = fixture([entry('registry', skill())])
|
|
48
48
|
const receipt = await hub.install('example/review', '1.0.0')
|
|
49
49
|
expect(receipt.resourceIds).toEqual(['skills/review'])
|
|
50
|
-
expect(registry.
|
|
50
|
+
expect(registry.list(root).map((item) => item.id)).toContain('skills/review')
|
|
51
51
|
const content = readFileSync(join(root, 'registry', 'skills', 'review', 'SKILL.md'), 'utf8')
|
|
52
52
|
const collision = { ...entry('registry', skill()), id: 'other/review' }
|
|
53
53
|
const another = new Hub(({ dataRoot: root, env: { root }, stateDir: join(root, 'state'), bus: new Bus() }) as Harness, { catalog: { schemaVersion: 1, entries: [collision] } })
|
|
@@ -67,7 +67,7 @@ describe('Hub installation', () => {
|
|
|
67
67
|
const { hub, root } = fixture([item])
|
|
68
68
|
await hub.install(item.id, item.version)
|
|
69
69
|
expect(readFileSync(join(root, 'registry', kind, 'review.md'), 'utf8')).toContain('Read evidence')
|
|
70
|
-
expect(registry.
|
|
70
|
+
expect(registry.list(root).map((entry) => entry.id)).toContain(`${kind}/review`)
|
|
71
71
|
}
|
|
72
72
|
})
|
|
73
73
|
it('does not commit a download with the wrong digest', async () => {
|
|
@@ -140,12 +140,11 @@ describe('Hub installation', () => {
|
|
|
140
140
|
it('can reinstall deleted resources and clears stale attachment grants', async () => {
|
|
141
141
|
const { hub, root } = fixture([entry('registry', skill())])
|
|
142
142
|
await hub.install('example/review', '1.0.0')
|
|
143
|
-
registry.setEnabled(root, null, ['skills/review'])
|
|
144
143
|
rmSync(join(root, 'registry', 'skills', 'review'), { recursive: true })
|
|
145
144
|
expect((await hub.list()).installed[0]).toMatchObject({ state: 'incomplete' })
|
|
146
145
|
await hub.uninstall('example/review', '1.0.0')
|
|
147
146
|
await hub.install('example/review', '1.0.0')
|
|
148
|
-
expect(registry.
|
|
147
|
+
expect(registry.list(root).map((entry) => entry.id)).toEqual(['skills/review'])
|
|
149
148
|
expect((await hub.list()).installed).toHaveLength(1)
|
|
150
149
|
})
|
|
151
150
|
it('preserves corrupt installation records rather than overwriting them', async () => {
|
package/src/hub/hub.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { atomicWrite, now, readJson, readJsonOr, writeJson } from '../core/fs'
|
|
|
8
8
|
import type { Harness } from '../harness'
|
|
9
9
|
import * as pluginApi from '../plugins/api'
|
|
10
10
|
import * as packages from '../plugins/install'
|
|
11
|
-
import * as registry from '../
|
|
11
|
+
import * as registry from '../tree/components'
|
|
12
12
|
import * as attachments from '../plugins/attachments'
|
|
13
13
|
import { version as harnessVersion } from '../../package.json'
|
|
14
14
|
import bundled from './catalog.json'
|
|
@@ -244,7 +244,7 @@ export class Hub {
|
|
|
244
244
|
const payload = JSON.parse(text)
|
|
245
245
|
const previous = new Set(registry.list(root).map((item) => item.id))
|
|
246
246
|
const imported = registry.importItems(root, delivery.registryKind, payload.items)
|
|
247
|
-
receipt.resourceIds = imported.
|
|
247
|
+
receipt.resourceIds = imported.filter((item) => !previous.has(item.id)).map((item) => item.id)
|
|
248
248
|
const choices = structuredClone(choicesBefore)
|
|
249
249
|
const ids = new Set(receipt.resourceIds)
|
|
250
250
|
for (const scope of [choices.global, ...Object.values(choices.agents), ...Object.values(choices.projects)]) {
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// @xingular/harness: a Pi agent harness
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// @xingular/harness: a Pi agent harness built on recursive harnessing (Harness², Apache-2.0,
|
|
2
|
+
// Google LLC). Each agent runs on a versioned harness tree of eight components (system prompt,
|
|
3
|
+
// guardrails, memory, tool guidance, subagents, skills, scripts, plugins); refinement improves a
|
|
4
|
+
// tree from evidence through Propose–Probe–Compose, blind selection and an experience library.
|
|
5
|
+
// Around it: multi-account Pi sessions, a compiled prompt with stable-prefix delivery, project
|
|
6
|
+
// notes (the Wiki), history, tools with a gate and standing approvals, and connected apps.
|
|
5
7
|
//
|
|
6
8
|
// An application embeds it with `createHarness` and extends it through prompt sections, tool
|
|
7
|
-
// providers, a tool policy, a secret store, a directory of its own names,
|
|
8
|
-
//
|
|
9
|
+
// providers, a tool policy, a secret store, a directory of its own names, a description of its
|
|
10
|
+
// own parts as plugins, and a refinement host. Everything else is reachable by path.
|
|
9
11
|
export { createHarness, Harness, type HarnessOptions, type Recovery } from './harness'
|
|
10
12
|
export { hubCatalogSchema, hubEntrySchema, hubOverviewSchema, hubReceiptSchema, type HubCatalog, type HubEntry, type HubOverview, type HubReceipt } from './hub/schema'
|
|
11
13
|
|
|
@@ -20,8 +22,8 @@ export type { AccountLoginSnapshot, ModelOption } from './accounts/accounts'
|
|
|
20
22
|
export type { Capability, CapabilityCatalogue, CapabilityKind, CapabilityScope, CapabilitySet } from './capabilities/types'
|
|
21
23
|
export type { RunCapabilitySnapshot } from './capabilities/runSnapshot'
|
|
22
24
|
|
|
23
|
-
export type { CallerContext } from './
|
|
24
|
-
export { dispatch as
|
|
25
|
+
export type { CallerContext } from './core/caller'
|
|
26
|
+
export { dispatch as harnessCall } from './tools/dispatch'
|
|
25
27
|
|
|
26
28
|
export {
|
|
27
29
|
compile,
|
|
@@ -30,7 +32,6 @@ export {
|
|
|
30
32
|
CONTEXT_MESSAGE_TYPE,
|
|
31
33
|
CONTEXT_SNAPSHOT_KEY,
|
|
32
34
|
type ContextPack,
|
|
33
|
-
type ContextProfile,
|
|
34
35
|
type HostSection,
|
|
35
36
|
type HostToolGroup,
|
|
36
37
|
type Launch,
|
|
@@ -45,19 +46,32 @@ export type { ActiveRun, ApprovalDecision, ApprovalInput, QuestionAnswer, Questi
|
|
|
45
46
|
export type { RunFields, SessionRunInput } from './sessions/sessions'
|
|
46
47
|
export type { Effort } from './sessions/runner'
|
|
47
48
|
|
|
48
|
-
export { describeTool,
|
|
49
|
+
export { describeTool, HARNESS_TOOLS, DEFAULT_TOOL_NAMES, WIKI_WRITE, type HarnessCall, type HarnessToolDefinition } from './tools/definitions'
|
|
49
50
|
export type { ToolProvider, ToolResult, ToolSession, ToolSpec } from './tools/types'
|
|
50
51
|
export { SPAWN_SUBAGENT, SUBAGENT_RELATION } from './tools/spawnSubagent'
|
|
51
|
-
export { maintenanceTools, MAINTENANCE_PLUGIN, MAINTENANCE_TOOL_NAMES } from './tools/maintenance'
|
|
52
|
-
export type { CurationJob } from './memory/curation'
|
|
53
52
|
export { webTools, WEB_PLUGIN, WEB_SEARCH_KEY, type WebToolOptions } from './web/tools'
|
|
54
|
-
export { registryTools, REGISTRY_WRITE, PLUGIN_WRITE, REGISTRY_WRITE_PLUGIN, type RegistryToolOptions } from './tools/registryWrite'
|
|
55
53
|
export { safeRequest, type WebRequest, type WebResponse } from './web/safeRequest'
|
|
56
54
|
|
|
57
55
|
export type { BrokeredTools, BrokerSession, BrokerTool } from './mcp/service'
|
|
58
56
|
export type { Connection, ConnectionInput, AppOAuthClients } from './mcp/types'
|
|
59
57
|
|
|
60
|
-
export { configureBuiltins, type
|
|
58
|
+
export { configureBuiltins, type ComponentItem, type SkillBundleInput } from './tree/components'
|
|
59
|
+
|
|
60
|
+
// The harness tree and refinement.
|
|
61
|
+
export { PI_SURFACE, componentOf, type ComponentGroup, type ComponentId, type ComponentInfo, type SurfaceSpec, type Tree } from './tree/surface'
|
|
62
|
+
export { validate as validateTree, autotrigger, diffSummary, TreeError, type TreeCensus, type TreeDiff } from './tree/tree'
|
|
63
|
+
export { fingerprint, type Head, type VersionRecord, type VersionSource } from './tree/store'
|
|
64
|
+
export { materialise, type HarnessChoice, type Materialised } from './tree/materialise'
|
|
65
|
+
export { seedTree, type SeedInput } from './tree/seed'
|
|
66
|
+
export type { TrustedPlugin } from './tree/trust'
|
|
67
|
+
export { DEFAULT_REFINE_HOST, defaultDeliverable, type RefineHost, type RolloutSession, type RolloutWorkspace } from './refine/host'
|
|
68
|
+
export { PROBE_TRIGGER, IMPROVER_TRIGGER, type ImproverSettings } from './refine/pi'
|
|
69
|
+
export type { CandidateView, StartInput as RefineStartInput } from './refine/service'
|
|
70
|
+
export type { RefineEvent, RefineStatus, RefineSummary } from './refine/runner'
|
|
71
|
+
export type { Executor, Improver, RecursionMode, RefineTask, Rollout } from './refine/types'
|
|
72
|
+
export type { WorkPrompts } from './refine/prompts'
|
|
73
|
+
export type { Experience } from './refine/library'
|
|
74
|
+
export type { Note, NoteSummary } from './wiki/notes'
|
|
61
75
|
|
|
62
76
|
export type { TerminalAssets, TerminalLaunch, TerminalPrepareInput } from './terminal/terminals'
|
|
63
77
|
|
package/src/mcp/connections.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Bus } from '../core/bus'
|
|
|
8
8
|
import { HarnessError, invalid, notFound } from '../core/errors'
|
|
9
9
|
import { newId, readJson, safeId, writeJson } from '../core/fs'
|
|
10
10
|
import { findBinary, pathVariableName, searchPath } from '../core/path'
|
|
11
|
-
import { isDir } from '../
|
|
11
|
+
import { isDir } from '../core/fs'
|
|
12
12
|
import type { RunEvent } from '../sessions/contracts'
|
|
13
13
|
import * as attachments from '../plugins/attachments'
|
|
14
14
|
import { connectionScopeMigrationComplete } from './scopeMigration'
|
package/src/mcp/service.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { HarnessError, invalid, notFound } from '../core/errors'
|
|
|
11
11
|
import { newId, now, readJson, writeJson } from '../core/fs'
|
|
12
12
|
import { charCount, takeChars } from '../core/text'
|
|
13
13
|
import type { CapabilitySet } from '../capabilities/types'
|
|
14
|
-
import type { CallerContext } from '../
|
|
14
|
+
import type { CallerContext } from '../core/caller'
|
|
15
15
|
import type { ToolPolicy } from '../sessions/policy'
|
|
16
16
|
import type { ActiveRun, RunRegistry } from '../sessions/registry'
|
|
17
17
|
import { findRun, runDir } from '../sessions/runStore'
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { promisify } from 'node:util'
|
|
4
|
+
import { afterEach, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile)
|
|
7
|
+
const servers: ReturnType<typeof createServer>[] = []
|
|
8
|
+
afterEach(async () => {
|
|
9
|
+
for (const server of servers.splice(0)) await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('routes Pi HTTP requests through the process proxy environment', async () => {
|
|
13
|
+
const requests: Array<{ url: string | undefined; host: string | undefined; tunnel: boolean }> = []
|
|
14
|
+
const proxy = createServer((request, response) => {
|
|
15
|
+
requests.push({ url: request.url, host: request.headers.host, tunnel: false })
|
|
16
|
+
response.writeHead(200, { 'content-type': 'text/plain' })
|
|
17
|
+
response.end('served by local proxy')
|
|
18
|
+
})
|
|
19
|
+
proxy.on('connect', (request, socket, head) => {
|
|
20
|
+
requests.push({ url: request.url, host: request.headers.host, tunnel: true })
|
|
21
|
+
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n')
|
|
22
|
+
let received = head.toString('utf8')
|
|
23
|
+
const respond = (): void => {
|
|
24
|
+
if (!received.includes('\r\n\r\n')) return
|
|
25
|
+
const body = 'served by local proxy'
|
|
26
|
+
socket.end(`HTTP/1.1 200 OK\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`)
|
|
27
|
+
}
|
|
28
|
+
socket.on('data', chunk => {
|
|
29
|
+
received += chunk.toString('utf8')
|
|
30
|
+
respond()
|
|
31
|
+
})
|
|
32
|
+
respond()
|
|
33
|
+
})
|
|
34
|
+
servers.push(proxy)
|
|
35
|
+
await new Promise<void>((resolve, reject) => {
|
|
36
|
+
proxy.once('error', reject)
|
|
37
|
+
proxy.listen(0, '127.0.0.1', resolve)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const address = proxy.address()
|
|
41
|
+
if (!address || typeof address === 'string') throw new Error('The local proxy did not bind to a TCP port.')
|
|
42
|
+
const proxyUrl = `http://127.0.0.1:${address.port}`
|
|
43
|
+
const script = `
|
|
44
|
+
import { join } from 'node:path'
|
|
45
|
+
import { pathToFileURL } from 'node:url'
|
|
46
|
+
|
|
47
|
+
const pi = await import('@earendil-works/pi-coding-agent')
|
|
48
|
+
const dispatcherUrl = pathToFileURL(join(pi.getPackageDir(), 'dist/bundle/chunks/chunk-TJXZSVTD.js')).href
|
|
49
|
+
const { configureHttpDispatcher } = await import(dispatcherUrl)
|
|
50
|
+
configureHttpDispatcher()
|
|
51
|
+
const response = await fetch('http://127.0.0.2/dispatcher-check')
|
|
52
|
+
if (response.status !== 200 || await response.text() !== 'served by local proxy') throw new Error('Pi did not receive the local proxy response')
|
|
53
|
+
`
|
|
54
|
+
const env: NodeJS.ProcessEnv = {
|
|
55
|
+
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
|
56
|
+
HTTP_PROXY: proxyUrl,
|
|
57
|
+
HTTPS_PROXY: proxyUrl,
|
|
58
|
+
NO_PROXY: 'localhost,127.0.0.1'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await execFileAsync(process.execPath, ['--input-type=module', '-e', script], {
|
|
62
|
+
cwd: process.cwd(),
|
|
63
|
+
env,
|
|
64
|
+
timeout: 10_000
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(requests).toEqual([{ url: '127.0.0.2:80', host: '127.0.0.2', tunnel: true }])
|
|
68
|
+
})
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { createPiLoader } from './runtime'
|
|
4
|
+
|
|
5
|
+
describe('Pi runtime initialization', () => {
|
|
6
|
+
it('configures Pi networking before returning the module and shares concurrent initialization', async () => {
|
|
7
|
+
const events: string[] = []
|
|
8
|
+
const pi = { VERSION: '1.2.0', getPackageDir: () => '/pinned/pi' }
|
|
9
|
+
const load = createPiLoader(
|
|
10
|
+
async () => {
|
|
11
|
+
events.push('import Pi')
|
|
12
|
+
return pi
|
|
13
|
+
},
|
|
14
|
+
async (packageDir) => {
|
|
15
|
+
events.push(`load dispatcher from ${packageDir}`)
|
|
16
|
+
return { configureHttpDispatcher: () => events.push('configure dispatcher') }
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const [first, second] = await Promise.all([load(), load()])
|
|
21
|
+
|
|
22
|
+
expect(first).toBe(pi)
|
|
23
|
+
expect(second).toBe(pi)
|
|
24
|
+
expect(events).toEqual(['import Pi', 'load dispatcher from /pinned/pi', 'configure dispatcher'])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects a Pi version change before loading its private dispatcher', async () => {
|
|
28
|
+
const loadDispatcher = vi.fn(async () => ({ configureHttpDispatcher: vi.fn() }))
|
|
29
|
+
const load = createPiLoader(async () => ({ VERSION: '1.3.0', getPackageDir: () => '/other/pi' }), loadDispatcher)
|
|
30
|
+
|
|
31
|
+
await expect(load()).rejects.toThrow('requires Pi 1.2.0')
|
|
32
|
+
expect(loadDispatcher).not.toHaveBeenCalled()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('reports when Pi no longer exposes its private dispatcher hook', async () => {
|
|
36
|
+
const load = createPiLoader(
|
|
37
|
+
async () => ({ VERSION: '1.2.0', getPackageDir: () => '/pinned/pi' }),
|
|
38
|
+
async () => ({})
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
await expect(load()).rejects.toThrow('pinned Pi 1.2.0 HTTP dispatcher hook')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('can retry initialization after a transient dispatcher load failure', async () => {
|
|
45
|
+
const configure = vi.fn()
|
|
46
|
+
let attempts = 0
|
|
47
|
+
const load = createPiLoader(
|
|
48
|
+
async () => ({ VERSION: '1.2.0', getPackageDir: () => '/pinned/pi' }),
|
|
49
|
+
async () => {
|
|
50
|
+
attempts++
|
|
51
|
+
if (attempts === 1) throw new Error('temporary import failure')
|
|
52
|
+
return { configureHttpDispatcher: configure }
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
await expect(load()).rejects.toThrow('temporary import failure')
|
|
57
|
+
await expect(load()).resolves.toMatchObject({ VERSION: '1.2.0' })
|
|
58
|
+
expect(attempts).toBe(2)
|
|
59
|
+
expect(configure).toHaveBeenCalledOnce()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
|
|
4
|
+
export const PINNED_PI_VERSION = '1.2.0'
|
|
5
|
+
const HTTP_DISPATCHER_CHUNK = 'dist/bundle/chunks/chunk-TJXZSVTD.js'
|
|
6
|
+
|
|
7
|
+
type PiRuntimeModule = {
|
|
8
|
+
VERSION: string
|
|
9
|
+
getPackageDir(): string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type PiDispatcherModule = {
|
|
13
|
+
configureHttpDispatcher?: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertPiRuntimeVersion(version: string): void {
|
|
17
|
+
if (version !== PINNED_PI_VERSION) throw new Error(`The Harness Pi runtime requires Pi ${PINNED_PI_VERSION}; found ${version}. Install the Harness package's pinned dependencies.`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Builds a cached Pi loader so initialization order and compatibility behavior can be verified. */
|
|
21
|
+
export function createPiLoader<T extends PiRuntimeModule>(
|
|
22
|
+
importPi: () => Promise<T>,
|
|
23
|
+
importDispatcher: (packageDir: string) => Promise<PiDispatcherModule>
|
|
24
|
+
): () => Promise<T> {
|
|
25
|
+
let loading: Promise<T> | undefined
|
|
26
|
+
|
|
27
|
+
return () => {
|
|
28
|
+
if (!loading) {
|
|
29
|
+
loading = (async () => {
|
|
30
|
+
const pi = await importPi()
|
|
31
|
+
assertPiRuntimeVersion(pi.VERSION)
|
|
32
|
+
const dispatcher = await importDispatcher(pi.getPackageDir())
|
|
33
|
+
if (typeof dispatcher.configureHttpDispatcher !== 'function') {
|
|
34
|
+
throw new Error(`The Harness requires the pinned Pi ${PINNED_PI_VERSION} HTTP dispatcher hook; install the Harness package's pinned dependencies.`)
|
|
35
|
+
}
|
|
36
|
+
dispatcher.configureHttpDispatcher()
|
|
37
|
+
return pi
|
|
38
|
+
})().catch((error: unknown) => {
|
|
39
|
+
loading = undefined
|
|
40
|
+
throw error
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
return loading
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const loadPi = createPiLoader(
|
|
48
|
+
() => import('@earendil-works/pi-coding-agent'),
|
|
49
|
+
async (packageDir) => await import(pathToFileURL(join(packageDir, HTTP_DISPATCHER_CHUNK)).href) as PiDispatcherModule
|
|
50
|
+
)
|
package/src/plugins/api.ts
CHANGED
|
@@ -6,10 +6,10 @@ import { ensureSharedDir, sharedDir } from '../accounts/env'
|
|
|
6
6
|
import { invalid, notFound } from '../core/errors'
|
|
7
7
|
import { findProject } from '../core/projects'
|
|
8
8
|
import type { Harness } from '../harness'
|
|
9
|
-
import type { CallerContext } from '../
|
|
9
|
+
import type { CallerContext } from '../core/caller'
|
|
10
10
|
import type { Launch } from '../prompt/compile'
|
|
11
|
+
import { materialise, type HarnessChoice } from '../tree/materialise'
|
|
11
12
|
import * as attachments from './attachments'
|
|
12
|
-
import { INCLUDED_PLUGIN, YOURS_PLUGIN } from './builtin'
|
|
13
13
|
import { catalogue } from './catalogue'
|
|
14
14
|
import { inventory } from './inventory'
|
|
15
15
|
import type { Loading } from './plugins'
|
|
@@ -67,6 +67,8 @@ export interface InventoryParams extends PluginScope {
|
|
|
67
67
|
launch?: Launch
|
|
68
68
|
tools?: string[]
|
|
69
69
|
connections?: boolean
|
|
70
|
+
/** The harness tree the caller runs, so what it adds is listed too. */
|
|
71
|
+
harness?: HarnessChoice | null
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export async function inventoryFor(harness: Harness, params: InventoryParams): Promise<Inventory> {
|
|
@@ -79,7 +81,8 @@ export async function inventoryFor(harness: Harness, params: InventoryParams): P
|
|
|
79
81
|
// What is switched off for the caller is not in what reaches it.
|
|
80
82
|
plugins: await list(harness, { ...params, agentId: params.agentId ?? params.context?.agentId ?? null, projectId: params.projectId ?? params.context?.projectId ?? null }),
|
|
81
83
|
descriptors: harness.descriptors,
|
|
82
|
-
saved: harness.apps.connections.list()
|
|
84
|
+
saved: harness.apps.connections.list(),
|
|
85
|
+
tree: params.harness ? materialise(harness.env.root, params.harness, { trust: harness.trust }) : null
|
|
83
86
|
})
|
|
84
87
|
}
|
|
85
88
|
|
|
@@ -129,13 +132,12 @@ export async function attach(harness: Harness, params: AttachParams, on: boolean
|
|
|
129
132
|
if (contribution?.kind === 'connection') {
|
|
130
133
|
harness.apps.attach(contribution.id.slice(`${plugin.id}/connection/`.length), scope, on)
|
|
131
134
|
} else {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
// off, a part that ships switched off is off, and anything found on disk is on.
|
|
135
|
+
// A choice is stored under the contribution's id and then its plugin's.
|
|
136
|
+
const ids = contribution ? [contribution.id, plugin.id] : [plugin.id]
|
|
137
|
+
// What it is until a person says otherwise: a part that ships switched off is off, and
|
|
138
|
+
// anything found on disk is on.
|
|
137
139
|
const shipped = harness.descriptors.find((descriptor) => descriptor.id === plugin.id)?.switchable
|
|
138
|
-
const fallback =
|
|
140
|
+
const fallback = shipped ? shipped.on : true
|
|
139
141
|
const choices = attachments.load(root)
|
|
140
142
|
attachments.set(choices, ids, scope, on, fallback)
|
|
141
143
|
attachments.save(root, choices)
|
package/src/plugins/builtin.ts
CHANGED
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
import { CONNECTION_TOOLS, NATIVE_GROUP } from '../prompt/compile'
|
|
4
4
|
import { ASK_USER } from '../tools/askUser'
|
|
5
5
|
import { SPAWN_SUBAGENT, WAIT_SUBAGENTS } from '../sessions/subagents'
|
|
6
|
-
import {
|
|
7
|
-
import { MEMORY_TOOLS, RECALL_TOOLS, TRAJECTORY_TOOLS, WIKI_TOOLS, type WikiToolDefinition } from '../tools/definitions'
|
|
6
|
+
import { RECALL_TOOLS, SKILL_TOOLS, TRAJECTORY_TOOLS, WIKI_READ_TOOLS, WIKI_WRITE_TOOLS, type HarnessToolDefinition } from '../tools/definitions'
|
|
8
7
|
import { FIRST_PARTY_PLUGINS } from './firstParty'
|
|
9
8
|
import type { PluginDescriptor } from './types'
|
|
10
9
|
|
|
11
|
-
const tools = (definitions: readonly
|
|
10
|
+
const tools = (definitions: readonly HarnessToolDefinition[]): PluginDescriptor['tools'] => definitions.map((tool) => ({ name: tool.name, description: tool.label }))
|
|
12
11
|
|
|
13
12
|
/** Pi itself. Its command's built-in extensions are added to it as they are found. */
|
|
14
13
|
export const PI_PLUGIN: PluginDescriptor = {
|
|
@@ -20,55 +19,39 @@ export const PI_PLUGIN: PluginDescriptor = {
|
|
|
20
19
|
toolGroups: ['Native']
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
/**
|
|
24
|
-
export const
|
|
25
|
-
id: '
|
|
26
|
-
title: '
|
|
27
|
-
description: '
|
|
28
|
-
|
|
22
|
+
/** The harness tree: the eight components that shape what an agent is told and can do. */
|
|
23
|
+
export const HARNESS_TREE_PLUGIN: PluginDescriptor = {
|
|
24
|
+
id: 'harness',
|
|
25
|
+
title: 'Harness',
|
|
26
|
+
description: 'The agent\'s own harness: system prompt, guardrails, memory and tool guidance in every prompt; skills, scripts and subagents on demand; plugins in the tool loop. Versioned, and refined from evidence.',
|
|
27
|
+
required: 'Every agent runs on its harness.',
|
|
29
28
|
sections: [
|
|
30
|
-
{ id: '
|
|
31
|
-
{ id: '
|
|
29
|
+
{ id: 'system', name: 'System prompt', description: 'c1: role and procedure.' },
|
|
30
|
+
{ id: 'guardrails', name: 'Guardrails', description: 'c2: standing rules.' },
|
|
31
|
+
{ id: 'memory', name: 'Memory', description: 'c3: durable facts about this kind of work.' },
|
|
32
|
+
{ id: 'subagents', name: 'Subagents', description: 'c5: the list of this harness\'s subagents.' },
|
|
33
|
+
{ id: 'scripts', name: 'Scripts', description: 'c7: where this harness\'s scripts are.' }
|
|
32
34
|
],
|
|
33
|
-
tools:
|
|
35
|
+
tools: tools(SKILL_TOOLS)
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
export const INCLUDED_PLUGIN: PluginDescriptor = { id: 'included', title: 'Included', description: 'Rules, skills and prompts that ship with the app.', manage: 'registry' }
|
|
37
|
-
|
|
38
38
|
export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
|
|
39
39
|
PI_PLUGIN,
|
|
40
40
|
{ id: 'subagents', title: 'Subagents', description: 'Isolated child runs of the same caller, bounded by parent capabilities and nesting depth.', required: 'Availability is selected by the session host; child runs retain the parent permission boundary.', tools: [{ name: SPAWN_SUBAGENT, description: 'Start a subagent' }, { name: WAIT_SUBAGENTS, description: 'Wait for subagents' }] },
|
|
41
|
-
|
|
42
|
-
id: 'memory',
|
|
43
|
-
title: 'Memory',
|
|
44
|
-
description: 'Blocks of text that are always in context and that the agent edits as it works.',
|
|
45
|
-
required: 'Every agent keeps its own memory, and the background upkeep depends on it.',
|
|
46
|
-
// Facts are memory too: what the agent wrote down, recalled on request.
|
|
47
|
-
tools: tools([...MEMORY_TOOLS, ...WIKI_TOOLS.filter((tool) => tool.name === 'memory_facts')]),
|
|
48
|
-
sections: [{ id: 'blocks', name: 'Memory blocks', description: 'Every block attached to the caller, in full.' }],
|
|
49
|
-
toolGroups: ['Memory']
|
|
50
|
-
},
|
|
41
|
+
HARNESS_TREE_PLUGIN,
|
|
51
42
|
{
|
|
52
43
|
id: 'wiki',
|
|
53
44
|
title: 'Wiki',
|
|
54
|
-
description: '
|
|
55
|
-
required: '
|
|
56
|
-
|
|
57
|
-
tools: tools(WIKI_TOOLS.filter((tool) => tool.name.startsWith('wiki_'))),
|
|
45
|
+
description: 'Notes about projects, linked to each other, that agents search and read.',
|
|
46
|
+
required: 'Project notes are reached through it.',
|
|
47
|
+
tools: tools([...WIKI_READ_TOOLS, ...WIKI_WRITE_TOOLS]),
|
|
58
48
|
sections: [
|
|
59
|
-
{ id: '
|
|
60
|
-
{ id: 'references', name: 'Selected references', description: '
|
|
49
|
+
{ id: 'wiki', name: 'Wiki', description: 'The current project\'s notes.' },
|
|
50
|
+
{ id: 'references', name: 'Selected references', description: 'Notes pinned to one request.' }
|
|
61
51
|
],
|
|
62
|
-
toolGroups: ['
|
|
63
|
-
},
|
|
64
|
-
{ id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]) },
|
|
65
|
-
{
|
|
66
|
-
id: 'plugin-proposals',
|
|
67
|
-
title: 'Plugin proposals',
|
|
68
|
-
description: 'Lets an agent propose a plugin. You accept it in the Inbox, switch it on, and trust its code; an agent can do none of those.',
|
|
69
|
-
required: 'It only proposes. Nothing is saved or run until you decide.',
|
|
70
|
-
tools: [{ name: PLUGIN_PROPOSE, description: 'Propose a plugin' }]
|
|
52
|
+
toolGroups: ['Wiki']
|
|
71
53
|
},
|
|
54
|
+
{ id: 'history', title: 'History', description: 'Search earlier conversations and inspect recorded run trajectories.', required: 'Earlier runs are how an agent picks up where work stopped.', tools: tools([...RECALL_TOOLS, ...TRAJECTORY_TOOLS]), toolGroups: ['History'] },
|
|
72
55
|
{ id: 'ask-user', title: 'Ask the user', description: 'Lets the agent stop and ask a question with choices.', required: 'An agent must be able to stop and ask you.', tools: [{ name: ASK_USER }] },
|
|
73
56
|
{
|
|
74
57
|
id: 'connected-apps',
|
|
@@ -79,6 +62,5 @@ export const HARNESS_PLUGINS: readonly PluginDescriptor[] = [
|
|
|
79
62
|
tools: CONNECTION_TOOLS.map((name) => ({ name })),
|
|
80
63
|
toolGroups: ['Connections']
|
|
81
64
|
},
|
|
82
|
-
YOURS_PLUGIN,
|
|
83
65
|
...FIRST_PARTY_PLUGINS
|
|
84
66
|
]
|
|
@@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
|
|
6
6
|
import { memorySecretStore } from '../accounts/secrets'
|
|
7
7
|
import { createHarness, type Harness } from '../harness'
|
|
8
8
|
import { methods } from '../server/methods'
|
|
9
|
-
import {
|
|
9
|
+
import { HARNESS_TOOLS } from '../tools/definitions'
|
|
10
10
|
import { WEB_PLUGIN } from '../web/tools'
|
|
11
11
|
import { rootWithProject, writeFile } from '../../tests/helpers'
|
|
12
12
|
import { HARNESS_PLUGINS } from './builtin'
|
|
@@ -68,12 +68,11 @@ describe('the plugin list', () => {
|
|
|
68
68
|
['project', 'project', ['code:local.ts:untrusted', 'instruction:AGENTS.md:on']],
|
|
69
69
|
['package:review-kit', 'package', ['code:guard.ts:on', 'command:guard:on', 'skill:audit:on']],
|
|
70
70
|
['home', 'home', ['skill:deploy:on']],
|
|
71
|
-
['yours', 'yours', ['instruction:Rules:on', 'instruction:Skills list:on', 'tool:skill_load:on', 'command:review:on', 'instruction:Run tests:on', 'skill:Release notes:on', 'skill:unused:off']],
|
|
72
71
|
['app-identity', 'application', ['instruction:Identity line:on']],
|
|
73
72
|
['app-tasks', 'application', ['tool:start_task:on']],
|
|
74
73
|
['web', 'application', ['tool:web_search:on', 'tool:web_fetch:on']]
|
|
75
74
|
])
|
|
76
|
-
expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', '
|
|
75
|
+
expect(plugins.filter((plugin) => plugin.source === 'builtin').map((plugin) => plugin.id)).toEqual(['ask-user', 'budget-guard', 'connected-apps', 'harness', 'history', 'output-trim', 'subagents', 'wiki'])
|
|
77
76
|
|
|
78
77
|
const kit = plugins.find((plugin) => plugin.id === 'package:review-kit')!
|
|
79
78
|
// It was there when trust was first asked per package, so it was taken over as trusted.
|
|
@@ -81,8 +80,6 @@ describe('the plugin list', () => {
|
|
|
81
80
|
expect(kit.contributions[0].description).toBe('Adds commands /guard; hooks tool_call.')
|
|
82
81
|
const project = plugins.find((plugin) => plugin.id === 'project')!
|
|
83
82
|
expect(project).toMatchObject({ runsCode: true, trust: 'untrusted', code: { files: ['.pi/extensions/local.ts'] } })
|
|
84
|
-
const yours = plugins.find((plugin) => plugin.id === 'yours')!
|
|
85
|
-
expect(yours.contributions.map((entry) => entry.scope)).toEqual([null, null, null, 'App', 'Everywhere', 'App', null])
|
|
86
83
|
const pi = plugins.find((plugin) => plugin.id === 'pi')!
|
|
87
84
|
expect(pi.required).toBe('Pi runs every session.')
|
|
88
85
|
expect(pi.contributions.filter((entry) => entry.kind === 'code').map((entry) => [entry.name, entry.state])).toEqual([['mcp', 'on'], ['llama.cpp', 'off']])
|
|
@@ -94,7 +91,7 @@ describe('the plugin list', () => {
|
|
|
94
91
|
expect(names.filter((name, index) => names.indexOf(name) !== index)).toEqual([])
|
|
95
92
|
}
|
|
96
93
|
const declared = HARNESS_PLUGINS.flatMap((plugin) => (plugin.tools ?? []).map((tool) => tool.name))
|
|
97
|
-
expect(
|
|
94
|
+
expect(HARNESS_TOOLS.map((tool) => tool.name).filter((name) => !declared.includes(name))).toEqual([])
|
|
98
95
|
})
|
|
99
96
|
|
|
100
97
|
it('lists nothing a folder brings when no folder is named, and what every account shares even before the first account exists', async () => {
|
|
@@ -133,20 +130,21 @@ describe('what reaches one caller', () => {
|
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
it('attributes every prompt section, tool group and tool to a plugin', async () => {
|
|
136
|
-
const { call, cwd } = setup()
|
|
137
|
-
const
|
|
133
|
+
const { call, cwd, harness } = setup()
|
|
134
|
+
const { commit } = await import('../tree/store')
|
|
135
|
+
commit(harness.env.root, 'a1', { 'systemprompt.md': 'You review changes.\n', 'guardrails.md': '- When writing release notes, load the `release` skill.\n', 'memory.md': '', 'tool_guidance.md': 'Prefer grep.\n', 'skills/release/SKILL.md': '---\nname: release\ndescription: writing release notes\n---\nSteps.\n' }, { kind: 'seed' })
|
|
136
|
+
const result = await call<Inventory>('plugins.inventory', { cwd, context: { agentId: 'a1', projectId: 'p1' }, launch, tools: ['read', 'bash', 'start_task', 'web_search'], harness: { scope: 'a1' } })
|
|
138
137
|
expect(result.unattributed).toEqual([])
|
|
139
138
|
const of = (plugin: string) => result.items.filter((item) => item.pluginId === plugin).map((item) => `${item.kind}:${item.name}:${item.delivery}`)
|
|
140
139
|
expect(of('app-identity')).toEqual(['instruction:Identity line:system'])
|
|
141
140
|
expect(of('app-tasks')).toEqual(['instruction:Tool guidance: Tasks:system', 'tool:start_task:tool'])
|
|
142
|
-
expect(of('
|
|
143
|
-
|
|
144
|
-
expect(of('
|
|
141
|
+
expect(of('harness')).toEqual(['instruction:System prompt:system', 'instruction:Guardrails:system', 'skill:release:on-demand', 'tool:skill_load:tool'])
|
|
142
|
+
expect(of('wiki')).toEqual(['instruction:Wiki:context', 'instruction:Tool guidance: Wiki:system', 'tool:wiki_search:tool', 'tool:wiki_read:tool', 'tool:wiki_list:tool'])
|
|
143
|
+
expect(of('history')).toEqual(['instruction:Tool guidance: History:system', 'tool:history_search:tool', 'tool:history_read:tool', 'tool:trajectory_search:tool', 'tool:trajectory_read:tool'])
|
|
145
144
|
expect(of('pi')).toEqual(['instruction:Tool guidance: Native:system', 'tool:read:tool', 'tool:bash:tool', 'instruction:Base prompt:system'])
|
|
146
145
|
// The working folder's code is not trusted, so only its instruction file reaches the caller.
|
|
147
146
|
expect(of('project')).toEqual(['instruction:AGENTS.md:system'])
|
|
148
147
|
expect(of('package:review-kit')).toEqual(['code:guard.ts:code', 'skill:audit:on-demand'])
|
|
149
|
-
expect(result.items.find((item) => item.name === 'Run tests')).toMatchObject({ contributionId: 'yours/rules/tests', cut: false })
|
|
150
148
|
expect(result.items.find((item) => item.name === 'AGENTS.md')?.chars).toBe(10)
|
|
151
149
|
expect(result.systemChars).toBeGreaterThan(0)
|
|
152
150
|
expect(result.contextChars).toBeGreaterThan(0)
|
|
@@ -177,31 +175,6 @@ describe('what reaches one caller', () => {
|
|
|
177
175
|
})
|
|
178
176
|
|
|
179
177
|
describe('switching a plugin on and off', () => {
|
|
180
|
-
const state = (plugins: Plugin[], id: string) => plugins.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === id)?.state
|
|
181
|
-
|
|
182
|
-
it('switches a registry item for one agent, and the agent then gets it in its prompt', async () => {
|
|
183
|
-
const { call } = setup()
|
|
184
|
-
const compiled = async (agentId: string) => (await call<{ text: string }>('context.compile', { context: { agentId, actor: 'Chat' }, render: true })).text
|
|
185
|
-
expect(await compiled('a1')).not.toContain('Release notes')
|
|
186
|
-
const after = await call<Plugin[]>('plugins.attach', { id: 'yours/skills/release', scope: { agentId: 'a1' } })
|
|
187
|
-
const release = after.flatMap((plugin) => plugin.contributions).find((entry) => entry.id === 'yours/skills/release')!
|
|
188
|
-
expect(release.attachment).toEqual({ global: false, on: { agentIds: ['a1'], projectIds: ['p1'] }, off: { agentIds: [], projectIds: [] } })
|
|
189
|
-
expect(release.scope).toBe('App, 1 agent')
|
|
190
|
-
expect(await compiled('a1')).toContain('Release notes')
|
|
191
|
-
expect(await compiled('a2')).not.toContain('Release notes')
|
|
192
|
-
// The earlier calls read the same choices.
|
|
193
|
-
expect((await call<any>('skills.list')).enabled).toEqual({ global: ['rules/tests'], p1: ['skills/release', 'prompts/review'] })
|
|
194
|
-
|
|
195
|
-
// A rule that is on everywhere is switched off for one agent only.
|
|
196
|
-
await call('plugins.detach', { id: 'yours/rules/tests', scope: { agentId: 'a1' } })
|
|
197
|
-
expect(await compiled('a1')).not.toContain('Always run the test suite')
|
|
198
|
-
expect(await compiled('a2')).toContain('Always run the test suite')
|
|
199
|
-
expect(state(await call<Plugin[]>('plugins.list', { agentId: 'a1' }), 'yours/rules/tests')).toBe('off')
|
|
200
|
-
expect(state(await call<Plugin[]>('plugins.list', { agentId: 'a2' }), 'yours/rules/tests')).toBe('on')
|
|
201
|
-
|
|
202
|
-
await call('plugins.forgetAgent', { agentId: 'a1' })
|
|
203
|
-
expect(await compiled('a1')).toContain('Always run the test suite')
|
|
204
|
-
})
|
|
205
178
|
|
|
206
179
|
it('stops a found plugin, or one item of it, from loading for the caller it was switched off for', async () => {
|
|
207
180
|
const { call, harness, cwd, base } = setup()
|
|
@@ -230,13 +203,12 @@ describe('switching a plugin on and off', () => {
|
|
|
230
203
|
|
|
231
204
|
it('refuses a required part, an application part, and a file Pi reads itself, and says why', async () => {
|
|
232
205
|
const { call, cwd } = setup()
|
|
233
|
-
await expect(call('plugins.detach', { id: '
|
|
234
|
-
await expect(call('plugins.detach', { id: '
|
|
206
|
+
await expect(call('plugins.detach', { id: 'harness' })).rejects.toThrow('Harness cannot be switched off. Every agent runs on its harness')
|
|
207
|
+
await expect(call('plugins.detach', { id: 'wiki/tool/wiki_read' })).rejects.toThrow('cannot be switched off')
|
|
235
208
|
await expect(call('plugins.detach', { id: 'app-tasks' })).rejects.toThrow('switched by the application')
|
|
236
|
-
await expect(call('plugins.detach', { id: 'yours' })).rejects.toThrow('one item at a time')
|
|
237
209
|
await expect(call('plugins.detach', { cwd, id: 'project/instruction/AGENTS.md' })).rejects.toThrow('cannot be switched on its own')
|
|
238
210
|
await expect(call('plugins.detach', { id: 'no-such-plugin' })).rejects.toThrow()
|
|
239
|
-
await expect(call('plugins.attach', { id: '
|
|
211
|
+
await expect(call('plugins.attach', { id: 'package:review-kit', scope: { projectId: 'nope' } })).rejects.toThrow()
|
|
240
212
|
})
|
|
241
213
|
|
|
242
214
|
it('switches a connected app for one agent through its own scope', async () => {
|