@xingular/harness 0.0.0-stage → 0.1.1
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/README.md +269 -2
- package/dist/cli.js +11510 -0
- package/dist/terminal-pi.js +326 -0
- package/package.json +41 -4
- package/src/accounts/accounts.test.ts +223 -0
- package/src/accounts/accounts.ts +522 -0
- package/src/accounts/env.test.ts +95 -0
- package/src/accounts/env.ts +338 -0
- package/src/accounts/secrets.ts +50 -0
- package/src/accounts/store.ts +338 -0
- package/src/capabilities/api.ts +75 -0
- package/src/capabilities/runSnapshot.ts +59 -0
- package/src/capabilities/types.ts +34 -0
- package/src/cli.ts +110 -0
- package/src/core/bus.ts +34 -0
- package/src/core/changes.ts +33 -0
- package/src/core/env.ts +27 -0
- package/src/core/errors.ts +32 -0
- package/src/core/frontmatter.ts +47 -0
- package/src/core/fs.test.ts +38 -0
- package/src/core/fs.ts +92 -0
- package/src/core/index.ts +193 -0
- package/src/core/path.ts +45 -0
- package/src/core/projects.ts +90 -0
- package/src/core/text.ts +39 -0
- package/src/harness.ts +299 -0
- package/src/index.ts +70 -0
- package/src/mcp/connections.ts +614 -0
- package/src/mcp/manager.ts +130 -0
- package/src/mcp/oauth.ts +195 -0
- package/src/mcp/scopeMigration.ts +204 -0
- package/src/mcp/service.test.ts +376 -0
- package/src/mcp/service.ts +549 -0
- package/src/mcp/types.ts +108 -0
- package/src/memory/api.test.ts +82 -0
- package/src/memory/api.ts +289 -0
- package/src/memory/blocks.test.ts +78 -0
- package/src/memory/blocks.ts +406 -0
- package/src/memory/caller.ts +57 -0
- package/src/memory/dispatch.ts +59 -0
- package/src/memory/facts.test.ts +41 -0
- package/src/memory/facts.ts +248 -0
- package/src/memory/graph.ts +103 -0
- package/src/memory/health.test.ts +130 -0
- package/src/memory/health.ts +74 -0
- package/src/memory/maintainer.ts +272 -0
- package/src/memory/maintenance.test.ts +176 -0
- package/src/memory/maintenance.ts +330 -0
- package/src/memory/migrate.ts +79 -0
- package/src/memory/operations.ts +138 -0
- package/src/memory/proposals.test.ts +168 -0
- package/src/memory/proposals.ts +576 -0
- package/src/memory/recall.test.ts +104 -0
- package/src/memory/recall.ts +271 -0
- package/src/memory/references.ts +77 -0
- package/src/memory/runContext.ts +93 -0
- package/src/memory/secrets.ts +6 -0
- package/src/memory/store.test.ts +66 -0
- package/src/memory/store.ts +385 -0
- package/src/memory/wiki.test.ts +47 -0
- package/src/memory/wiki.ts +422 -0
- package/src/memory/wikiIndex.test.ts +88 -0
- package/src/memory/wikiIndex.ts +116 -0
- package/src/plugins/api.ts +181 -0
- package/src/plugins/attachments.test.ts +84 -0
- package/src/plugins/attachments.ts +141 -0
- package/src/plugins/builtin.ts +82 -0
- package/src/plugins/catalogue.test.ts +254 -0
- package/src/plugins/catalogue.ts +251 -0
- package/src/plugins/cli.ts +184 -0
- package/src/plugins/discover.ts +151 -0
- package/src/plugins/firstParty.test.ts +143 -0
- package/src/plugins/firstParty.ts +129 -0
- package/src/plugins/hooks.test.ts +250 -0
- package/src/plugins/hooks.ts +239 -0
- package/src/plugins/identity.ts +37 -0
- package/src/plugins/install.test.ts +107 -0
- package/src/plugins/install.ts +106 -0
- package/src/plugins/inventory.ts +123 -0
- package/src/plugins/manifest.ts +76 -0
- package/src/plugins/plugins.test.ts +205 -0
- package/src/plugins/plugins.ts +189 -0
- package/src/plugins/proposal.ts +121 -0
- package/src/plugins/runtime.test.ts +40 -0
- package/src/plugins/runtime.ts +82 -0
- package/src/plugins/trust.ts +142 -0
- package/src/plugins/types.ts +149 -0
- package/src/prompt/compile.test.ts +261 -0
- package/src/prompt/compile.ts +732 -0
- package/src/server/methods.ts +314 -0
- package/src/server/remote.ts +77 -0
- package/src/server/rpc.ts +116 -0
- package/src/server/serve.ts +28 -0
- package/src/server/server.test.ts +174 -0
- package/src/sessions/approvals.ts +118 -0
- package/src/sessions/contracts.ts +299 -0
- package/src/sessions/conversation.test.ts +150 -0
- package/src/sessions/extensionSession.test.ts +74 -0
- package/src/sessions/extensionUi.test.ts +65 -0
- package/src/sessions/extensionUi.ts +102 -0
- package/src/sessions/history.ts +51 -0
- package/src/sessions/lineageJoin.ts +75 -0
- package/src/sessions/policy.ts +32 -0
- package/src/sessions/pricing.ts +60 -0
- package/src/sessions/reasoningSummaries.ts +59 -0
- package/src/sessions/registry.ts +294 -0
- package/src/sessions/runIndex.ts +99 -0
- package/src/sessions/runStore.test.ts +152 -0
- package/src/sessions/runStore.ts +332 -0
- package/src/sessions/runner.ts +556 -0
- package/src/sessions/runtimeResources.test.ts +61 -0
- package/src/sessions/sessions.test.ts +434 -0
- package/src/sessions/sessions.ts +295 -0
- package/src/skills/registry.test.ts +104 -0
- package/src/skills/registry.ts +407 -0
- package/src/terminal/cli.ts +149 -0
- package/src/terminal/client.ts +74 -0
- package/src/terminal/controller.ts +227 -0
- package/src/terminal/piExtension.ts +208 -0
- package/src/terminal/pluginControl.test.ts +146 -0
- package/src/terminal/pluginMenu.ts +112 -0
- package/src/terminal/socket.ts +182 -0
- package/src/terminal/standalone.test.ts +115 -0
- package/src/terminal/standalone.ts +126 -0
- package/src/terminal/terminal.test.ts +302 -0
- package/src/terminal/terminals.ts +429 -0
- package/src/tools/askUser.ts +45 -0
- package/src/tools/definitions.ts +187 -0
- package/src/tools/memoryTools.ts +22 -0
- package/src/tools/pluginPropose.ts +41 -0
- package/src/tools/types.ts +36 -0
- package/src/web/safeRequest.ts +171 -0
- package/src/web/text.ts +53 -0
- package/src/web/tools.ts +134 -0
- package/src/web/web.test.ts +141 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// A session turn on Pi. Pi owns the model loop, native history, and tool use; this module opens
|
|
2
|
+
// the session with the compiled prompt and the session's tools, and maps Pi's events onto the
|
|
3
|
+
// run-event contract. Gated tools pause for the user's approval before they run.
|
|
4
|
+
import type { CreateAgentSessionOptions, ModelRuntime, SettingsManager } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
|
|
6
|
+
import { classifyMessage } from '../accounts/accounts'
|
|
7
|
+
import type { AccountContext } from '../accounts/env'
|
|
8
|
+
import { HarnessError } from '../core/errors'
|
|
9
|
+
import type { InlineExtension } from '../plugins/firstParty'
|
|
10
|
+
import type { Loading, Registered } from '../plugins/plugins'
|
|
11
|
+
import { CONTEXT_MESSAGE_TYPE, CONTEXT_SNAPSHOT_KEY, type ContextUpdate } from '../prompt/compile'
|
|
12
|
+
import type { RegistryResources } from '../skills/registry'
|
|
13
|
+
import type { ToolSpec } from '../tools/types'
|
|
14
|
+
import type { UsageRequest } from './contracts'
|
|
15
|
+
import { completedReasoningSummary, REASONING_SUMMARY_APIS, requestConciseReasoningSummary } from './reasoningSummaries'
|
|
16
|
+
import { extensionUi, type ExtensionHost } from './extensionUi'
|
|
17
|
+
import { observeResources, registrationHash } from '../plugins/runtime'
|
|
18
|
+
import type { ActiveRun } from './registry'
|
|
19
|
+
|
|
20
|
+
export const EFFORTS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
21
|
+
export type Effort = (typeof EFFORTS)[number]
|
|
22
|
+
|
|
23
|
+
/** What one turn needs once its prompt is compiled and its tools are chosen. */
|
|
24
|
+
export interface TurnParams {
|
|
25
|
+
runId: string
|
|
26
|
+
cwd: string
|
|
27
|
+
sessionDir: string
|
|
28
|
+
/** The native history to open, or `null` to start a fresh one. */
|
|
29
|
+
historyFile: string | null
|
|
30
|
+
model: string | null
|
|
31
|
+
effort?: Effort | null
|
|
32
|
+
text: string
|
|
33
|
+
images?: { mediaType: string; data: string }[]
|
|
34
|
+
requiresImageInput?: boolean
|
|
35
|
+
/** App-origin notes placed before the text of the request. */
|
|
36
|
+
preamble?: string | null
|
|
37
|
+
/** Unattended: no project extensions, no context files, settings held in memory. */
|
|
38
|
+
automatic?: boolean
|
|
39
|
+
account: AccountContext
|
|
40
|
+
resources?: RegistryResources
|
|
41
|
+
/**
|
|
42
|
+
* What the harness found for this session to load, and where it reports what loaded. Pi's own
|
|
43
|
+
* discovery is off whenever this is set, so only these paths and the registry's reach the session.
|
|
44
|
+
*/
|
|
45
|
+
plugins?: { loading(): Promise<Loading>; loaded(extensions: Registered[]): void }
|
|
46
|
+
/** The harness's own switched-on extensions. They are code in this process, so they load in every kind of run. */
|
|
47
|
+
inline?: InlineExtension[]
|
|
48
|
+
/** The stable part of the compiled prompt: the only text added to Pi's own. */
|
|
49
|
+
system: string
|
|
50
|
+
/** Changed state, saved as a hidden message in native history before the request. */
|
|
51
|
+
update: ContextUpdate | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- Pi session seam (real implementation below; tests inject a fake) ----------------
|
|
55
|
+
|
|
56
|
+
/** Structural view of the Pi events the harness consumes. */
|
|
57
|
+
export type PiEvent =
|
|
58
|
+
| { type: 'message_start'; message: { role: string } }
|
|
59
|
+
| { type: 'message_update'; message: { role: string }; assistantMessageEvent: { type: string; delta?: string } }
|
|
60
|
+
| {
|
|
61
|
+
type: 'message_end'
|
|
62
|
+
message: {
|
|
63
|
+
role: string
|
|
64
|
+
content?: Array<{ type: string; text?: string; thinkingSignature?: string }>
|
|
65
|
+
api?: string
|
|
66
|
+
usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; reasoning?: number; cost?: { total?: number } }
|
|
67
|
+
model?: string
|
|
68
|
+
provider?: string
|
|
69
|
+
responseId?: string
|
|
70
|
+
stopReason?: string
|
|
71
|
+
errorMessage?: string
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
| { type: 'tool_execution_start'; toolCallId: string; toolName: string; args: unknown }
|
|
75
|
+
| { type: 'tool_execution_end'; toolCallId: string; toolName: string; isError: boolean; result?: unknown }
|
|
76
|
+
| { type: string }
|
|
77
|
+
|
|
78
|
+
export interface PiSessionLike {
|
|
79
|
+
sessionId: string
|
|
80
|
+
systemPrompt?: string
|
|
81
|
+
extensionCommands?: readonly string[]
|
|
82
|
+
model?: { input: string[]; contextWindow?: number }
|
|
83
|
+
getActiveToolNames?(): string[]
|
|
84
|
+
getAllTools?(): Array<{ name: string; description?: string; parameters?: unknown }>
|
|
85
|
+
subscribe(listener: (event: PiEvent) => void): () => void
|
|
86
|
+
prompt(text: string, options?: { images?: unknown[] }): Promise<void>
|
|
87
|
+
sendCustomMessage?(message: { customType: string; content: string; display: boolean; details: unknown }, options: { triggerTurn: boolean }): Promise<void>
|
|
88
|
+
abort(): Promise<void>
|
|
89
|
+
dispose(): void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ToolGate {
|
|
93
|
+
/** Resolves to a block reason, or null to let the call run. */
|
|
94
|
+
check(toolName: string, input: unknown): Promise<string | null>
|
|
95
|
+
/** Makes these tools ask before each call. Used for tools an extension brought, which no one switched on by name. */
|
|
96
|
+
ask?(toolNames: readonly string[]): void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type SessionFactory = (input: {
|
|
100
|
+
params: TurnParams
|
|
101
|
+
/** Pi's own tools and the custom tools switched on for this turn, in order. */
|
|
102
|
+
allow: string[]
|
|
103
|
+
gate: ToolGate
|
|
104
|
+
/** Custom tools already bound to this run. */
|
|
105
|
+
tools: ToolSpec[]
|
|
106
|
+
runtime: () => Promise<ModelRuntime>
|
|
107
|
+
/** Where a loaded extension's dialogs and notices go. Without it extensions have no interface. */
|
|
108
|
+
extensions?: ExtensionHost
|
|
109
|
+
}) => Promise<PiSessionLike>
|
|
110
|
+
|
|
111
|
+
/** A one-line description of a tool call, for an approval request or the activity list. */
|
|
112
|
+
export function summarize(input: unknown, toolName = ''): string {
|
|
113
|
+
if (input && typeof input === 'object') {
|
|
114
|
+
const record = input as Record<string, unknown>
|
|
115
|
+
// Typed text is never recorded, only its length.
|
|
116
|
+
if (/_type$/.test(toolName) && typeof record.text === 'string') return `${String(record.selector ?? record.app ?? '')} (${record.text.length} characters)`.trim()
|
|
117
|
+
const primary = record.command ?? record.path ?? record.file_path ?? record.query
|
|
118
|
+
if (typeof primary === 'string') return primary.slice(0, 200)
|
|
119
|
+
}
|
|
120
|
+
return JSON.stringify(input ?? {}).slice(0, 200)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function assistantText(message: { content?: Array<{ type: string; text?: string }> }): string {
|
|
124
|
+
return (message.content ?? []).filter((part) => part.type === 'text' && typeof part.text === 'string').map((part) => part.text).join('')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** How much text a tool handed back to the model: what its result adds to every later request. */
|
|
128
|
+
export function resultSize(result: unknown): number | null {
|
|
129
|
+
if (typeof result === 'string') return result.length
|
|
130
|
+
const content = (result as { content?: unknown } | null | undefined)?.content
|
|
131
|
+
if (!Array.isArray(content)) return null
|
|
132
|
+
return content.reduce((total: number, part: { type?: string; text?: unknown }) => total + (part?.type === 'text' && typeof part.text === 'string' ? part.text.length : 0), 0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const MAX_EVIDENCE_CHARS = 24_000
|
|
136
|
+
const MAX_SYSTEM_PROMPT_CHARS = 100_000
|
|
137
|
+
const MAX_TOOL_SPECS = 120
|
|
138
|
+
|
|
139
|
+
function limitedText(value: string, maximum = MAX_EVIDENCE_CHARS): { text: string; truncated: boolean } {
|
|
140
|
+
const characters = Array.from(value)
|
|
141
|
+
const marker = '\n…[truncated]'
|
|
142
|
+
return characters.length > maximum ? { text: `${characters.slice(0, Math.max(0, maximum - Array.from(marker).length)).join('')}${marker}`, truncated: true } : { text: value, truncated: false }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function evidenceText(value: unknown): string | null {
|
|
146
|
+
if (typeof value === 'string') return value
|
|
147
|
+
if (value === undefined) return null
|
|
148
|
+
const content = (value as { content?: unknown } | null)?.content
|
|
149
|
+
if (Array.isArray(content)) {
|
|
150
|
+
const parts = content.flatMap((part) => {
|
|
151
|
+
if (!part || typeof part !== 'object') return []
|
|
152
|
+
const item = part as { type?: unknown; text?: unknown }
|
|
153
|
+
if (item.type === 'text' && typeof item.text === 'string') return [item.text]
|
|
154
|
+
return item.type === 'image' ? ['[image content omitted]'] : []
|
|
155
|
+
})
|
|
156
|
+
if (parts.length) return parts.join('\n')
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
return JSON.stringify(value, null, 2) ?? String(value)
|
|
160
|
+
} catch {
|
|
161
|
+
return String(value)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function boundedSchema(value: unknown, budget: { remaining: number }, depth = 0): { value: unknown; truncated: boolean } {
|
|
166
|
+
if (budget.remaining <= 0 || depth > 12) return { value: '[truncated]', truncated: true }
|
|
167
|
+
if (typeof value === 'string') {
|
|
168
|
+
const { text, truncated } = limitedText(value, Math.min(MAX_EVIDENCE_CHARS, budget.remaining))
|
|
169
|
+
budget.remaining -= Array.from(text).length
|
|
170
|
+
return { value: text, truncated }
|
|
171
|
+
}
|
|
172
|
+
if (Array.isArray(value)) {
|
|
173
|
+
const values: unknown[] = []
|
|
174
|
+
let truncated = value.length > 200
|
|
175
|
+
for (const item of value.slice(0, 200)) {
|
|
176
|
+
const next = boundedSchema(item, budget, depth + 1)
|
|
177
|
+
values.push(next.value)
|
|
178
|
+
truncated ||= next.truncated
|
|
179
|
+
if (budget.remaining <= 0) break
|
|
180
|
+
}
|
|
181
|
+
return { value: values, truncated: truncated || values.length < value.length }
|
|
182
|
+
}
|
|
183
|
+
if (value && typeof value === 'object') {
|
|
184
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
185
|
+
const output: Record<string, unknown> = {}
|
|
186
|
+
let truncated = entries.length > 200
|
|
187
|
+
for (const [key, item] of entries.slice(0, 200)) {
|
|
188
|
+
if (budget.remaining <= 0) {
|
|
189
|
+
truncated = true
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
const next = boundedSchema(item, budget, depth + 1)
|
|
193
|
+
output[key] = next.value
|
|
194
|
+
truncated ||= next.truncated
|
|
195
|
+
}
|
|
196
|
+
return { value: output, truncated }
|
|
197
|
+
}
|
|
198
|
+
return { value: value ?? null, truncated: false }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function contextToolSpecs(session: PiSessionLike): { tools: Array<{ name: string; description: string; parameters: unknown }>; truncated: boolean } {
|
|
202
|
+
const allTools = session.getAllTools?.() ?? []
|
|
203
|
+
const activeNames = session.getActiveToolNames?.()
|
|
204
|
+
const source = activeNames ? allTools.filter((tool) => activeNames.includes(tool.name)) : allTools
|
|
205
|
+
const tools = source.slice(0, MAX_TOOL_SPECS).map((tool) => {
|
|
206
|
+
const description = limitedText(tool.description ?? '', 2_000)
|
|
207
|
+
const parameters = boundedSchema(tool.parameters ?? {}, { remaining: 4_000 })
|
|
208
|
+
return { spec: { name: tool.name, description: description.text, parameters: parameters.value }, truncated: description.truncated || parameters.truncated }
|
|
209
|
+
})
|
|
210
|
+
return { tools: tools.map((entry) => entry.spec), truncated: source.length > MAX_TOOL_SPECS || tools.some((entry) => entry.truncated) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const numberField = (event: Record<string, unknown>, key: string, fallback = 0): number => (typeof event[key] === 'number' && Number.isFinite(event[key]) ? (event[key] as number) : fallback)
|
|
214
|
+
const stringField = (event: Record<string, unknown>, key: string, fallback = ''): string => (typeof event[key] === 'string' ? (event[key] as string) : fallback)
|
|
215
|
+
|
|
216
|
+
/** Streams one request through a Pi session, translating events for the run. */
|
|
217
|
+
export async function runTurn(run: ActiveRun, session: PiSessionLike, params: TurnParams): Promise<void> {
|
|
218
|
+
let messageIndex = 0
|
|
219
|
+
let requestIndex = 0
|
|
220
|
+
let currentMessageId: string | null = null
|
|
221
|
+
let lastError: string | null = null
|
|
222
|
+
let aborted = false
|
|
223
|
+
const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0, requests: 0 }
|
|
224
|
+
let usageReported = false
|
|
225
|
+
// Only some providers report a reasoning split; absent stays absent rather than zero.
|
|
226
|
+
let reasoningTokens: number | undefined
|
|
227
|
+
// One model request runs from a turn's start to its assistant message's end.
|
|
228
|
+
let requestStartedAt: number | null = null
|
|
229
|
+
let firstOutputAt: number | null = null
|
|
230
|
+
let costReported = false
|
|
231
|
+
const toolStartedAt = new Map<string, number>()
|
|
232
|
+
|
|
233
|
+
const systemPrompt = limitedText(session.systemPrompt ?? params.system, MAX_SYSTEM_PROMPT_CHARS)
|
|
234
|
+
const contextUpdate = limitedText(params.update?.content ?? '', MAX_SYSTEM_PROMPT_CHARS)
|
|
235
|
+
const toolSpecs = contextToolSpecs(session)
|
|
236
|
+
run.emit('context.snapshot', {
|
|
237
|
+
systemPrompt: systemPrompt.text,
|
|
238
|
+
...(contextUpdate.text ? { contextUpdate: contextUpdate.text } : {}),
|
|
239
|
+
tools: toolSpecs.tools,
|
|
240
|
+
...(systemPrompt.truncated ? { systemPromptTruncated: true } : {}),
|
|
241
|
+
...(contextUpdate.truncated ? { contextUpdateTruncated: true } : {}),
|
|
242
|
+
...(toolSpecs.truncated ? { toolsTruncated: true } : {})
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
run.onInterrupt(async () => {
|
|
246
|
+
aborted = true
|
|
247
|
+
await session.abort()
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const unsubscribe = session.subscribe((event) => {
|
|
251
|
+
switch (event.type) {
|
|
252
|
+
case 'message_start': {
|
|
253
|
+
const { message } = event as Extract<PiEvent, { type: 'message_start' }>
|
|
254
|
+
if (message.role === 'assistant') currentMessageId = `${run.runId}:m${messageIndex++}`
|
|
255
|
+
break
|
|
256
|
+
}
|
|
257
|
+
case 'turn_start': {
|
|
258
|
+
requestStartedAt = Date.now()
|
|
259
|
+
firstOutputAt = null
|
|
260
|
+
break
|
|
261
|
+
}
|
|
262
|
+
case 'message_update': {
|
|
263
|
+
const update = event as Extract<PiEvent, { type: 'message_update' }>
|
|
264
|
+
if (update.message.role === 'assistant') firstOutputAt ??= Date.now()
|
|
265
|
+
if (update.message.role === 'assistant' && update.assistantMessageEvent.type === 'text_delta' && update.assistantMessageEvent.delta) {
|
|
266
|
+
currentMessageId ??= `${run.runId}:m${messageIndex++}`
|
|
267
|
+
run.emit('message.delta', { messageId: currentMessageId, text: update.assistantMessageEvent.delta })
|
|
268
|
+
}
|
|
269
|
+
break
|
|
270
|
+
}
|
|
271
|
+
case 'message_end': {
|
|
272
|
+
const { message } = event as Extract<PiEvent, { type: 'message_end' }>
|
|
273
|
+
if (message.role !== 'assistant') break
|
|
274
|
+
requestIndex += 1
|
|
275
|
+
const messageId = currentMessageId ?? `${run.runId}:m${messageIndex++}`
|
|
276
|
+
currentMessageId = null
|
|
277
|
+
const text = assistantText(message)
|
|
278
|
+
if (text) run.emit('message.completed', { messageId, role: 'assistant', text })
|
|
279
|
+
const reasoningSummary = completedReasoningSummary(message)
|
|
280
|
+
if (message.usage || reasoningSummary) {
|
|
281
|
+
const reported = message.usage
|
|
282
|
+
if (reported) {
|
|
283
|
+
usageReported = true
|
|
284
|
+
usage.inputTokens += reported.input ?? 0
|
|
285
|
+
usage.outputTokens += reported.output ?? 0
|
|
286
|
+
usage.cacheReadTokens += reported.cacheRead ?? 0
|
|
287
|
+
usage.cacheWriteTokens += reported.cacheWrite ?? 0
|
|
288
|
+
usage.costUsd += reported.cost?.total ?? 0
|
|
289
|
+
if (typeof reported.reasoning === 'number') reasoningTokens = (reasoningTokens ?? 0) + reported.reasoning
|
|
290
|
+
}
|
|
291
|
+
usage.requests = requestIndex
|
|
292
|
+
const cost = reported?.cost?.total
|
|
293
|
+
if (typeof cost === 'number') costReported = true
|
|
294
|
+
const ended = Date.now()
|
|
295
|
+
const request: UsageRequest = {
|
|
296
|
+
index: requestIndex,
|
|
297
|
+
...(message.responseId ? { responseId: message.responseId } : {}),
|
|
298
|
+
...(message.model ? { model: message.model } : {}),
|
|
299
|
+
...(message.provider ? { provider: message.provider } : {}),
|
|
300
|
+
...(reported
|
|
301
|
+
? {
|
|
302
|
+
inputTokens: reported.input ?? 0,
|
|
303
|
+
cacheReadTokens: reported.cacheRead ?? 0,
|
|
304
|
+
cacheWriteTokens: reported.cacheWrite ?? 0,
|
|
305
|
+
outputTokens: reported.output ?? 0,
|
|
306
|
+
...(typeof reported.reasoning === 'number' ? { reasoningTokens: reported.reasoning } : {})
|
|
307
|
+
}
|
|
308
|
+
: {}),
|
|
309
|
+
...(reasoningSummary ? { reasoningSummary } : {}),
|
|
310
|
+
...(typeof cost === 'number' ? { costUsd: cost, costSource: 'harness' as const } : {}),
|
|
311
|
+
...(requestStartedAt === null ? {} : { durationMs: ended - requestStartedAt }),
|
|
312
|
+
...(requestStartedAt === null || firstOutputAt === null ? {} : { ttftMs: Math.max(0, firstOutputAt - requestStartedAt) }),
|
|
313
|
+
...(message.stopReason ? { stopReason: message.stopReason } : {})
|
|
314
|
+
}
|
|
315
|
+
requestStartedAt = null
|
|
316
|
+
const contextWindow = session.model?.contextWindow
|
|
317
|
+
const { costUsd, ...tokens } = usage
|
|
318
|
+
run.emit('usage.updated', {
|
|
319
|
+
...(usageReported ? tokens : { requests: requestIndex }),
|
|
320
|
+
// A cost nobody reported is unknown, not zero.
|
|
321
|
+
...(usageReported && costReported ? { costUsd, costSource: 'harness' as const } : {}),
|
|
322
|
+
...(reasoningTokens === undefined ? {} : { reasoningTokens }),
|
|
323
|
+
...(reported ? { contextTokens: (reported.input ?? 0) + (reported.cacheRead ?? 0) + (reported.cacheWrite ?? 0) } : {}),
|
|
324
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
325
|
+
...(message.model ? { model: message.model } : {}),
|
|
326
|
+
...(message.provider ? { provider: message.provider } : {}),
|
|
327
|
+
request
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
if (message.stopReason === 'error' && message.errorMessage) {
|
|
331
|
+
lastError = message.errorMessage
|
|
332
|
+
const category = classifyMessage(message.errorMessage)
|
|
333
|
+
if (category) run.emit('account.status', { accountId: params.account.id, category, message: message.errorMessage.slice(0, 500) })
|
|
334
|
+
}
|
|
335
|
+
if (message.stopReason === 'aborted') aborted = true
|
|
336
|
+
break
|
|
337
|
+
}
|
|
338
|
+
case 'tool_execution_start': {
|
|
339
|
+
const tool = event as Extract<PiEvent, { type: 'tool_execution_start' }>
|
|
340
|
+
toolStartedAt.set(tool.toolCallId, Date.now())
|
|
341
|
+
const input = evidenceText(tool.args)
|
|
342
|
+
const boundedInput = input === null ? null : limitedText(input)
|
|
343
|
+
run.emit('tool.started', {
|
|
344
|
+
toolCallId: tool.toolCallId,
|
|
345
|
+
name: tool.toolName,
|
|
346
|
+
summary: summarize(tool.args, tool.toolName),
|
|
347
|
+
...(boundedInput ? { input: boundedInput.text, ...(boundedInput.truncated ? { inputTruncated: true } : {}) } : {})
|
|
348
|
+
})
|
|
349
|
+
break
|
|
350
|
+
}
|
|
351
|
+
case 'tool_execution_end': {
|
|
352
|
+
const tool = event as Extract<PiEvent, { type: 'tool_execution_end' }>
|
|
353
|
+
const started = toolStartedAt.get(tool.toolCallId)
|
|
354
|
+
toolStartedAt.delete(tool.toolCallId)
|
|
355
|
+
const resultChars = resultSize(tool.result)
|
|
356
|
+
const output = evidenceText(tool.result)
|
|
357
|
+
const boundedOutput = output === null ? null : limitedText(output)
|
|
358
|
+
run.emit('tool.completed', {
|
|
359
|
+
toolCallId: tool.toolCallId,
|
|
360
|
+
name: tool.toolName,
|
|
361
|
+
ok: !tool.isError,
|
|
362
|
+
...(started === undefined ? {} : { durationMs: Date.now() - started }),
|
|
363
|
+
...(resultChars === null ? {} : { resultChars }),
|
|
364
|
+
...(boundedOutput ? { output: boundedOutput.text, ...(boundedOutput.truncated ? { outputTruncated: true } : {}) } : {})
|
|
365
|
+
})
|
|
366
|
+
break
|
|
367
|
+
}
|
|
368
|
+
case 'auto_retry_start': {
|
|
369
|
+
const retry = event as unknown as Record<string, unknown>
|
|
370
|
+
run.emit('retry.started', {
|
|
371
|
+
attempt: numberField(retry, 'attempt', 1),
|
|
372
|
+
maxAttempts: numberField(retry, 'maxAttempts', 1),
|
|
373
|
+
delayMs: numberField(retry, 'delayMs'),
|
|
374
|
+
...(stringField(retry, 'errorMessage') ? { error: stringField(retry, 'errorMessage').slice(0, 1000) } : {})
|
|
375
|
+
})
|
|
376
|
+
break
|
|
377
|
+
}
|
|
378
|
+
case 'auto_retry_end': {
|
|
379
|
+
const retry = event as unknown as Record<string, unknown>
|
|
380
|
+
run.emit('retry.ended', { attempt: numberField(retry, 'attempt', 1), success: retry.success === true, ...(stringField(retry, 'finalError') ? { error: stringField(retry, 'finalError').slice(0, 1000) } : {}) })
|
|
381
|
+
break
|
|
382
|
+
}
|
|
383
|
+
case 'compaction_start': {
|
|
384
|
+
run.emit('compaction.started', { reason: stringField(event as unknown as Record<string, unknown>, 'reason', 'context limit') })
|
|
385
|
+
break
|
|
386
|
+
}
|
|
387
|
+
case 'compaction_end': {
|
|
388
|
+
const compaction = event as unknown as Record<string, unknown>
|
|
389
|
+
const summary = evidenceText(compaction.result)
|
|
390
|
+
const boundedSummary = summary ? limitedText(summary) : null
|
|
391
|
+
run.emit('compaction.ended', {
|
|
392
|
+
reason: stringField(compaction, 'reason', 'context limit'),
|
|
393
|
+
aborted: compaction.aborted === true,
|
|
394
|
+
...(typeof compaction.willRetry === 'boolean' ? { willRetry: compaction.willRetry } : {}),
|
|
395
|
+
...(stringField(compaction, 'errorMessage') ? { error: stringField(compaction, 'errorMessage').slice(0, 1000) } : {}),
|
|
396
|
+
...(boundedSummary ? { summary: boundedSummary.text, ...(boundedSummary.truncated ? { summaryTruncated: true } : {}) } : {})
|
|
397
|
+
})
|
|
398
|
+
break
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
if (params.update) {
|
|
405
|
+
if (!session.sendCustomMessage) throw new HarnessError('provider_error', 'The Pi session cannot receive the compiled context update.')
|
|
406
|
+
// The delivered snapshot is saved with the message, so the next turn compares against what Pi really holds.
|
|
407
|
+
await session.sendCustomMessage({ customType: CONTEXT_MESSAGE_TYPE, content: params.update.content, display: false, details: { [CONTEXT_SNAPSHOT_KEY]: params.update.snapshot } }, { triggerTurn: false })
|
|
408
|
+
}
|
|
409
|
+
const command = /^\/(\S+)/.exec(params.text)?.[1]
|
|
410
|
+
const extensionCommand = command && session.extensionCommands?.includes(command)
|
|
411
|
+
if (params.preamble && extensionCommand) {
|
|
412
|
+
if (!session.sendCustomMessage) throw new HarnessError('provider_error', 'The Pi session cannot receive the command context.')
|
|
413
|
+
await session.sendCustomMessage({ customType: 'xingular-preamble', content: params.preamble, display: false, details: {} }, { triggerTurn: false })
|
|
414
|
+
}
|
|
415
|
+
const prompt = params.preamble && !extensionCommand ? `${params.preamble}\n\n${params.text}` : params.text
|
|
416
|
+
const images = (params.images ?? []).map((image) => ({ type: 'image', mimeType: image.mediaType, data: image.data }))
|
|
417
|
+
await session.prompt(prompt, images.length ? { images } : undefined)
|
|
418
|
+
if (aborted) run.finish('interrupted')
|
|
419
|
+
else if (lastError) run.finish('failed', { code: 'provider_error', message: lastError })
|
|
420
|
+
else run.finish('completed')
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (aborted) run.finish('interrupted')
|
|
423
|
+
else run.finish('failed', { code: 'provider_error', message: error instanceof Error ? error.message : String(error) })
|
|
424
|
+
} finally {
|
|
425
|
+
unsubscribe()
|
|
426
|
+
session.dispose()
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Loads what a session may use besides its tools: Pi's context files, the registry's skills and
|
|
432
|
+
* prompts, and what the harness found for it. `gate` is loaded after every extension on disk.
|
|
433
|
+
*/
|
|
434
|
+
export async function loadResources(pi: typeof import('@earendil-works/pi-coding-agent'), params: Pick<TurnParams, 'cwd' | 'account' | 'automatic' | 'resources' | 'plugins' | 'system' | 'inline'>, gate: unknown, automaticSettings?: SettingsManager) {
|
|
435
|
+
const loading = params.automatic ? null : ((await params.plugins?.loading()) ?? null)
|
|
436
|
+
const resourceLoader = new pi.DefaultResourceLoader({
|
|
437
|
+
...(automaticSettings ? { settingsManager: automaticSettings, noExtensions: true, noContextFiles: true } : {}),
|
|
438
|
+
// The loader gets empty settings and no discovery of its own: it loads the paths below and
|
|
439
|
+
// nothing else. The session still reads the account's real settings for everything but resources.
|
|
440
|
+
...(loading ? { settingsManager: pi.SettingsManager.inMemory({}), noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, additionalExtensionPaths: loading.extensionPaths } : {}),
|
|
441
|
+
cwd: params.cwd,
|
|
442
|
+
agentDir: params.account.configDir,
|
|
443
|
+
// Inline extensions load after the ones on disk, and the gate after all of them, so it sees
|
|
444
|
+
// what every plugin made of a call.
|
|
445
|
+
extensionFactories: [...(params.inline ?? []).map((extension) => ({ name: extension.name, factory: extension.factory as never, hidden: true })), { name: 'agent-x-approvals', factory: gate as never, hidden: true }],
|
|
446
|
+
additionalSkillPaths: [...(params.resources?.skillPaths ?? []), ...(loading?.skillPaths ?? [])],
|
|
447
|
+
additionalPromptTemplatePaths: [...(params.resources?.promptPaths ?? []), ...(loading?.promptPaths ?? [])],
|
|
448
|
+
// The compiler writes one prompt; nothing else is appended here.
|
|
449
|
+
appendSystemPromptOverride: (base) => [...base, ...(params.system.trim() ? [params.system] : [])]
|
|
450
|
+
})
|
|
451
|
+
await resourceLoader.reload()
|
|
452
|
+
if (loading) {
|
|
453
|
+
const records = registeredBy(resourceLoader).map((record) => ({ ...record, hash: registrationHash(loading, record.path) }))
|
|
454
|
+
const publish = () => params.plugins?.loaded(records)
|
|
455
|
+
observeResources(pi, resourceLoader, loading, records, publish)
|
|
456
|
+
publish()
|
|
457
|
+
}
|
|
458
|
+
return resourceLoader
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** What each extension on disk registered when it was loaded. The harness's own gate is not one of them. */
|
|
462
|
+
export function registeredBy(loader: { getExtensions(): { extensions: readonly unknown[] } }): Registered[] {
|
|
463
|
+
const names = (map: { keys(): Iterable<unknown> } | undefined): string[] => (map ? [...map.keys()].map(String) : [])
|
|
464
|
+
type Loaded = { hidden?: boolean; path: string; resolvedPath?: string } & Record<'handlers' | 'tools' | 'commands' | 'flags' | 'shortcuts', { keys(): Iterable<unknown> } | undefined>
|
|
465
|
+
return (loader.getExtensions().extensions as Loaded[])
|
|
466
|
+
.filter((extension) => !extension.hidden)
|
|
467
|
+
.map((extension) => ({ path: extension.resolvedPath || extension.path, hooks: names(extension.handlers), tools: names(extension.tools), commands: names(extension.commands), flags: names(extension.flags), shortcuts: names(extension.shortcuts) }))
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Opens a Pi session for one turn: the account's runtime, the compiled prompt, and the gated tools. */
|
|
471
|
+
export const piSessionFactory: SessionFactory = async ({ params, allow, gate, tools, runtime: loadRuntime, extensions: host }) => {
|
|
472
|
+
const pi = await import('@earendil-works/pi-coding-agent')
|
|
473
|
+
const { Type } = await import('typebox')
|
|
474
|
+
const runtime = await loadRuntime()
|
|
475
|
+
|
|
476
|
+
let model: unknown
|
|
477
|
+
let thinkingLevel: CreateAgentSessionOptions['thinkingLevel'] = params.effort ?? undefined
|
|
478
|
+
if (params.model) {
|
|
479
|
+
const resolved = pi.resolveCliModel({ cliModel: params.model, modelRuntime: runtime })
|
|
480
|
+
if (resolved.error || !resolved.model) throw new HarnessError('provider_unavailable', resolved.error ?? `Model ${params.model} is not available.`)
|
|
481
|
+
model = resolved.model
|
|
482
|
+
thinkingLevel ??= resolved.thinkingLevel
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const gatedExtension = (api: { on: (event: 'tool_call', handler: (event: { toolName: string; input: unknown }) => Promise<unknown>) => void }) => {
|
|
486
|
+
api.on('tool_call', async (event) => {
|
|
487
|
+
const reason = await gate.check(event.toolName, event.input)
|
|
488
|
+
return reason ? { block: true, reason } : undefined
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const agentDir = params.account.configDir
|
|
493
|
+
const automaticSettings = params.automatic ? pi.SettingsManager.inMemory({}) : undefined
|
|
494
|
+
const resourceLoader = await loadResources(pi, params, gatedExtension, automaticSettings)
|
|
495
|
+
const loaded = registeredBy(resourceLoader)
|
|
496
|
+
// A tool an extension brought is offered to the model, and asks before every call: the person
|
|
497
|
+
// trusted the code to load, and still decides each thing it does through a tool.
|
|
498
|
+
const extensionTools = [...new Set(loaded.flatMap((extension) => extension.tools))].filter((name) => !allow.includes(name))
|
|
499
|
+
gate.ask?.([...new Set(loaded.flatMap((extension) => extension.tools))])
|
|
500
|
+
|
|
501
|
+
let supportsImageInput = false
|
|
502
|
+
const customTools = tools
|
|
503
|
+
.filter((tool) => allow.includes(tool.name))
|
|
504
|
+
.map((spec) =>
|
|
505
|
+
pi.defineTool({
|
|
506
|
+
name: spec.name,
|
|
507
|
+
label: spec.label,
|
|
508
|
+
description: spec.description,
|
|
509
|
+
parameters: spec.parameters(Type) as never,
|
|
510
|
+
execute: async (_toolCallId, input) => {
|
|
511
|
+
const result = await spec.execute(input as Record<string, unknown>)
|
|
512
|
+
const output = typeof result === 'string' ? { text: result } : result
|
|
513
|
+
if (output.image && !supportsImageInput) throw new HarnessError('image_input_unsupported', 'This model cannot inspect images. Choose a model that supports image input.')
|
|
514
|
+
return { content: [{ type: 'text', text: output.text }, ...(output.image ? [{ type: 'image', data: output.image.data, mimeType: output.image.mimeType }] : [])] as never, details: {} }
|
|
515
|
+
}
|
|
516
|
+
})
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
const { session } = await pi.createAgentSession({
|
|
520
|
+
cwd: params.cwd,
|
|
521
|
+
agentDir,
|
|
522
|
+
modelRuntime: runtime,
|
|
523
|
+
...(model ? { model: model as never } : {}),
|
|
524
|
+
...(thinkingLevel ? { thinkingLevel } : {}),
|
|
525
|
+
tools: [...allow, ...extensionTools],
|
|
526
|
+
customTools: customTools as never,
|
|
527
|
+
resourceLoader,
|
|
528
|
+
...(automaticSettings ? { settingsManager: automaticSettings } : {}),
|
|
529
|
+
sessionManager: params.historyFile ? pi.SessionManager.open(params.historyFile, params.sessionDir, params.cwd) : pi.SessionManager.continueRecent(params.cwd, params.sessionDir)
|
|
530
|
+
})
|
|
531
|
+
if (loaded.length && host) {
|
|
532
|
+
// Without this an extension never hears that its session started and has nobody to ask.
|
|
533
|
+
await session.bindExtensions({
|
|
534
|
+
uiContext: extensionUi(host),
|
|
535
|
+
mode: 'rpc',
|
|
536
|
+
onError: (error) => host.notice({ kind: 'error', text: `${error.event}: ${error.error}`.slice(0, 1000), extension: error.extensionPath })
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
const piSession = session as unknown as PiSessionLike & { agent: { streamFunction: typeof runtime.streamSimple } }
|
|
540
|
+
piSession.extensionCommands = loaded.flatMap((extension) => extension.commands)
|
|
541
|
+
supportsImageInput = piSession.model?.input.includes('image') ?? false
|
|
542
|
+
const streamAgent = piSession.agent
|
|
543
|
+
const originalStream = streamAgent.streamFunction
|
|
544
|
+
streamAgent.streamFunction = (streamModel, context, options) => {
|
|
545
|
+
if (!streamModel.reasoning || !REASONING_SUMMARY_APIS.has(streamModel.api)) return originalStream(streamModel, context, options)
|
|
546
|
+
return originalStream(streamModel, context, {
|
|
547
|
+
...options,
|
|
548
|
+
onPayload: async (payload, requestModel) => {
|
|
549
|
+
const overridden = await options?.onPayload?.(payload, requestModel)
|
|
550
|
+
const requested = requestConciseReasoningSummary(overridden ?? payload)
|
|
551
|
+
return requested.requested || overridden !== undefined ? requested.payload : undefined
|
|
552
|
+
}
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
return piSession
|
|
556
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { memorySecretStore } from '../accounts/secrets'
|
|
7
|
+
import { createHarness, type Harness } from '../harness'
|
|
8
|
+
import { methods } from '../server/methods'
|
|
9
|
+
import { writeFile } from '../../tests/helpers'
|
|
10
|
+
import { fauxModel } from '../../tests/faux'
|
|
11
|
+
import type { Inventory, Plugin } from '../plugins/types'
|
|
12
|
+
import { piSessionFactory } from './runner'
|
|
13
|
+
|
|
14
|
+
const open: Harness[] = []
|
|
15
|
+
afterEach(async () => { for (const harness of open.splice(0)) await harness.close() })
|
|
16
|
+
|
|
17
|
+
it('attributes discovered resources and tools, respects caller switches, and discards stale observations', async () => {
|
|
18
|
+
const base = mkdtempSync(join(tmpdir(), 'runtime-resources-'))
|
|
19
|
+
const harness = createHarness({ dataRoot: join(base, 'data'), stateDir: join(base, 'state'), secrets: memorySecretStore() })
|
|
20
|
+
open.push(harness)
|
|
21
|
+
harness.accounts.create('Main', 'apiKey')
|
|
22
|
+
const account = harness.accounts.context(harness.accounts.resolve(null, null))
|
|
23
|
+
await fauxModel(harness)
|
|
24
|
+
const skill = join(base, 'resources/audit/SKILL.md')
|
|
25
|
+
const prompt = join(base, 'resources/review.md')
|
|
26
|
+
writeFile(skill, '---\nname: runtime-audit\ndescription: Audit at runtime.\n---\nAudit instructions.\n')
|
|
27
|
+
writeFile(prompt, '---\nname: runtime-review\ndescription: Review at runtime.\n---\nReview instructions.\n')
|
|
28
|
+
const extension = join(account.configDir, 'extensions/kit.ts')
|
|
29
|
+
writeFile(extension, `import { Type } from 'typebox'
|
|
30
|
+
export default function(pi) {
|
|
31
|
+
pi.registerTool({ name: 'kit_scan', label: 'Scan', description: 'Scan', parameters: Type.Object({}), async execute() { return { content: [], details: {} } } })
|
|
32
|
+
pi.on('resources_discover', () => ({ skillPaths: [${JSON.stringify(skill)}], promptPaths: [${JSON.stringify(prompt)}] }))
|
|
33
|
+
}`)
|
|
34
|
+
const table = methods(harness)
|
|
35
|
+
const load = async (agentId: string) => piSessionFactory({ params: { runId: 'r', cwd: base, sessionDir: join(base, agentId), historyFile: null, model: null, text: '', account, system: '', update: null,
|
|
36
|
+
plugins: { loading: () => harness.plugins.loading({ cwd: base, agentDir: account.configDir, caller: { agentId } }), loaded: (records) => harness.plugins.loaded(records) } },
|
|
37
|
+
allow: ['read'], tools: [], gate: { check: async () => null }, runtime: () => harness.accounts.runtime(account), extensions: { ask: async () => ({ selected: [] }), notice: () => {} } })
|
|
38
|
+
const first = await load('a1')
|
|
39
|
+
expect(first.systemPrompt).toContain('runtime-audit')
|
|
40
|
+
first.dispose()
|
|
41
|
+
const list = await table['plugins.list']({ cwd: base }) as Plugin[]
|
|
42
|
+
expect(list.find((plugin) => plugin.id === 'shared')?.contributions).toEqual(expect.arrayContaining([
|
|
43
|
+
expect.objectContaining({ kind: 'tool', name: 'kit_scan', state: 'on' }),
|
|
44
|
+
expect.objectContaining({ kind: 'skill', name: 'runtime-audit', path: skill, state: 'on' }),
|
|
45
|
+
expect.objectContaining({ kind: 'command', name: 'runtime-review', path: prompt })
|
|
46
|
+
]))
|
|
47
|
+
await table['plugins.detach']({ cwd: base, id: 'shared/skill/runtime-audit', scope: { agentId: 'a1' } })
|
|
48
|
+
const off = await load('a1')
|
|
49
|
+
expect(off.systemPrompt).not.toContain('runtime-audit')
|
|
50
|
+
off.dispose()
|
|
51
|
+
const on = await load('a2')
|
|
52
|
+
expect(on.systemPrompt).toContain('runtime-audit')
|
|
53
|
+
on.dispose()
|
|
54
|
+
const inventory = await table['plugins.inventory']({ cwd: base, context: { agentId: 'a2' }, tools: ['read', 'kit_scan'] }) as Inventory
|
|
55
|
+
expect(inventory.items).toEqual(expect.arrayContaining([expect.objectContaining({ pluginId: 'shared', kind: 'tool', name: 'kit_scan' }), expect.objectContaining({ pluginId: 'shared', kind: 'skill', name: 'runtime-audit', chars: expect.any(Number) })]))
|
|
56
|
+
expect(inventory.unattributed).toEqual([])
|
|
57
|
+
expect(inventory.items.filter((entry) => entry.kind === 'tool' && entry.name === 'kit_scan')).toHaveLength(1)
|
|
58
|
+
writeFile(extension, 'export default function() {}\n')
|
|
59
|
+
const changed = await table['plugins.list']({ cwd: base }) as Plugin[]
|
|
60
|
+
expect(changed.find((plugin) => plugin.id === 'shared')?.contributions.some((item) => item.name === 'runtime-audit' || item.name === 'kit_scan')).toBe(false)
|
|
61
|
+
})
|