pipeline-moe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of pipeline-moe might be problematic. Click here for more details.
- package/.env.example +26 -0
- package/LICENSE +21 -0
- package/README.md +408 -0
- package/bin/pipeline-moe.mjs +40 -0
- package/package.json +68 -0
- package/presets/2106BUILD.json +163 -0
- package/presets/CHEAPBUILD.json +97 -0
- package/presets/FREEROOM.json +96 -0
- package/presets/Versa.json +145 -0
- package/presets/cloud-main.json +76 -0
- package/presets/cloud-sprint.json +70 -0
- package/presets/local-default.json +152 -0
- package/presets/main.json +147 -0
- package/presets/mainmix.json +145 -0
- package/src/circuit-breaker.ts +141 -0
- package/src/config.ts +46 -0
- package/src/custom-tools/arxiv-search.ts +186 -0
- package/src/custom-tools/check-room.ts +45 -0
- package/src/custom-tools/destroy-room.ts +45 -0
- package/src/custom-tools/index.ts +75 -0
- package/src/custom-tools/spawn-room.ts +99 -0
- package/src/custom-tools/stop-room.ts +50 -0
- package/src/custom-tools/web-read.ts +104 -0
- package/src/custom-tools/web-search.ts +144 -0
- package/src/custom-tools/youcom-search.ts +230 -0
- package/src/custom-tools/youtube-transcript.ts +124 -0
- package/src/local-model-lock.ts +52 -0
- package/src/model.ts +131 -0
- package/src/orchestrator.ts +59 -0
- package/src/participant.ts +423 -0
- package/src/path-guard.ts +13 -0
- package/src/personas.ts +480 -0
- package/src/receipts.ts +120 -0
- package/src/registry.ts +317 -0
- package/src/room-manager.ts +505 -0
- package/src/room.ts +1657 -0
- package/src/sandbox-tools.ts +141 -0
- package/src/server.ts +1846 -0
- package/src/sse.ts +86 -0
- package/src/sshfs.ts +139 -0
- package/src/store.ts +79 -0
- package/src/types.ts +131 -0
- package/src/validation.ts +36 -0
- package/tsconfig.json +15 -0
- package/web/dist/assets/index-CmMGhMKG.css +1 -0
- package/web/dist/assets/index-LAGqbZII.js +41 -0
- package/web/dist/index.html +13 -0
- package/web/tsconfig.json +21 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// RoomOrchestrator — the capability surface that lets an agent (the planner)
|
|
2
|
+
// spawn, inspect, and tear down sub-rooms from inside a turn.
|
|
3
|
+
//
|
|
4
|
+
// The interface lives here, decoupled from its implementation (server.ts) and
|
|
5
|
+
// from the tools that consume it (custom-tools/spawn-room.ts etc.). This avoids
|
|
6
|
+
// a circular import between the tool registry and the server/room-manager, and
|
|
7
|
+
// keeps the tools testable against a mock orchestrator.
|
|
8
|
+
|
|
9
|
+
export interface SpawnRoomOptions {
|
|
10
|
+
/** Display name for the sub-room. */
|
|
11
|
+
name: string
|
|
12
|
+
/** The goal the sub-room's agents work on autonomously. */
|
|
13
|
+
goal: string
|
|
14
|
+
/** Preset roster name (from presets/). Omit to use the default roster. */
|
|
15
|
+
preset?: string
|
|
16
|
+
/** Working directory scope — local path or user@host:/path (sshfs).
|
|
17
|
+
* Omit for the pipeline workspace. */
|
|
18
|
+
workspaceDir?: string
|
|
19
|
+
/** Goal completion mode. "auto" (default): the goal completes when the
|
|
20
|
+
* pipeline drains naturally. "eval": after each drain the evaluator agent
|
|
21
|
+
* verifies the goal independently and either dispatches more work or declares
|
|
22
|
+
* GOAL_MET. */
|
|
23
|
+
goalMode?: "auto" | "eval"
|
|
24
|
+
/** Agent id that evaluates the goal in "eval" mode. Defaults to "planner". */
|
|
25
|
+
goalEvaluator?: string
|
|
26
|
+
/** Max eval iterations before the goal auto-fails (eval mode only). Default 10. */
|
|
27
|
+
maxGoalIterations?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SpawnRoomResult {
|
|
31
|
+
roomId: string
|
|
32
|
+
name: string
|
|
33
|
+
goalStatus: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CheckRoomResult {
|
|
37
|
+
found: boolean
|
|
38
|
+
roomId: string
|
|
39
|
+
name?: string
|
|
40
|
+
goalStatus?: string
|
|
41
|
+
goalText?: string | null
|
|
42
|
+
/** The last few transcript lines, "Author: text" formatted. */
|
|
43
|
+
lastMessages?: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RoomOrchestrator {
|
|
47
|
+
/** Create a room, load its roster, submit its goal. Fire-and-forget — the
|
|
48
|
+
* room runs in the background and this resolves once it has started. */
|
|
49
|
+
spawnRoom(opts: SpawnRoomOptions): Promise<SpawnRoomResult>
|
|
50
|
+
/** Read a sub-room's current goal status and recent transcript. */
|
|
51
|
+
checkRoom(roomId: string): CheckRoomResult
|
|
52
|
+
/** Stop a sub-room's in-flight pipeline WITHOUT destroying it: aborts running
|
|
53
|
+
* agents and cancels any goal (status → "cancelled"), leaving the room and its
|
|
54
|
+
* transcript intact so the caller can inspect why it ran away. Returns false
|
|
55
|
+
* when the room is absent or protected (the default room cannot be stopped). */
|
|
56
|
+
stopRoom(roomId: string): Promise<boolean>
|
|
57
|
+
/** Destroy a sub-room (aborts it, then unmounts any sshfs target). Returns false if absent. */
|
|
58
|
+
destroyRoom(roomId: string): Promise<boolean>
|
|
59
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// A Participant wraps one pi AgentSession (one persona) and exposes a simple
|
|
2
|
+
// run()/dispose() lifecycle. Its session keeps its own conversation memory
|
|
3
|
+
// across turns (stateful). The shared room transcript is threaded in by Room.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createAgentSession,
|
|
7
|
+
DefaultResourceLoader,
|
|
8
|
+
getAgentDir,
|
|
9
|
+
SessionManager,
|
|
10
|
+
SettingsManager,
|
|
11
|
+
type AgentSession,
|
|
12
|
+
type AgentSessionEvent,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent"
|
|
14
|
+
import { readFileSync } from "node:fs"
|
|
15
|
+
import { access, readFile } from "node:fs/promises"
|
|
16
|
+
import { constants } from "node:fs"
|
|
17
|
+
import { join } from "node:path"
|
|
18
|
+
import { config } from "./config.js"
|
|
19
|
+
import { buildConfinedTools } from "./sandbox-tools.js"
|
|
20
|
+
import { buildCustomTools } from "./custom-tools/index.js"
|
|
21
|
+
import { resolveModelRef, type ResolvedModel } from "./model.js"
|
|
22
|
+
import type { RoomOrchestrator } from "./orchestrator.js"
|
|
23
|
+
import type { Persona, ParticipantStatus, ToolActivity } from "./types.js"
|
|
24
|
+
|
|
25
|
+
/** Cap a tool result/arg to keep SSE frames and persisted transcripts small. */
|
|
26
|
+
function clip(value: unknown, max = 2000): string {
|
|
27
|
+
let s: string
|
|
28
|
+
if (typeof value === "string") s = value
|
|
29
|
+
else {
|
|
30
|
+
try {
|
|
31
|
+
s = JSON.stringify(value)
|
|
32
|
+
} catch {
|
|
33
|
+
s = String(value)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return s.length > max ? `${s.slice(0, max)}… (+${s.length - max} chars)` : s
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The workspace-scope note, parameterised by the room's actual root directory.
|
|
40
|
+
* A room scoped to the pipeline workspace gets the shared-workspace wording;
|
|
41
|
+
* a room scoped elsewhere (e.g. another project, or the machine root) is told
|
|
42
|
+
* exactly which directory its file tools are confined to. */
|
|
43
|
+
function workspaceNote(root: string): string {
|
|
44
|
+
return (
|
|
45
|
+
`Your working directory is ${root}. Use paths relative to it ` +
|
|
46
|
+
"(e.g. `notes.md`, `src/app.ts`). Never read or write outside it — absolute paths " +
|
|
47
|
+
"pointing outside this root are denied."
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ROOM_NOTE =
|
|
52
|
+
"You are one agent in a shared multi-agent chat room. Other agents are addressed by " +
|
|
53
|
+
"@<id> in lowercase (e.g. @scout, @builder, @auditor, @scribe, @tester). To hand work " +
|
|
54
|
+
"to another agent, write @<id> EXPLICITLY in your reply — a plain sentence like " +
|
|
55
|
+
"'over to the builder' does NOT trigger anything. Only @-mention an agent when you " +
|
|
56
|
+
"genuinely need it. Do not @-mention yourself, and do not use @all (that is for the human).\n" +
|
|
57
|
+
"You can refer to other agents by name in discussion (e.g. 'the builder said...') without " +
|
|
58
|
+
"triggering a handoff — only the @prefix routes work.\n" +
|
|
59
|
+
"When handing off work to another agent, @-mention them in your reply — " +
|
|
60
|
+
"any @mention triggers routing. Example: 'I've finished the analysis. @builder please implement " +
|
|
61
|
+
"the fix above.'\n" +
|
|
62
|
+
"If you need information only the user can provide (preferences, credentials, context), " +
|
|
63
|
+
"use the ask_user tool — it will pause the pipeline and wait for their response. Do NOT " +
|
|
64
|
+
"use it for rhetorical questions or self-clarification.\n" +
|
|
65
|
+
"Your personal memory lives at agent_memory/<your_id>.md (e.g. agent_memory/builder.md). " +
|
|
66
|
+
"Read it at the start of a task to recall prior context. The scribe updates these files. " +
|
|
67
|
+
"After a compaction, your memory is refreshed automatically."
|
|
68
|
+
|
|
69
|
+
export type Emit = (event: "token" | "status" | "activity" | "reasoning", data: unknown) => void
|
|
70
|
+
|
|
71
|
+
/** What a turn produced: the final text plus the tool calls made to get there. */
|
|
72
|
+
export interface TurnResult {
|
|
73
|
+
text: string
|
|
74
|
+
activity: ToolActivity[]
|
|
75
|
+
/** Reasoning trace accumulated during the turn. */
|
|
76
|
+
reasoning?: string
|
|
77
|
+
/** If the agent called ask_user, the question text. */
|
|
78
|
+
question?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class Participant {
|
|
82
|
+
readonly persona: Persona
|
|
83
|
+
active = true
|
|
84
|
+
/** When true, may run concurrently with adjacent parallel-flagged agents. */
|
|
85
|
+
parallel = false
|
|
86
|
+
status: ParticipantStatus = "idle"
|
|
87
|
+
/** Index of the next room transcript entry this participant has NOT yet seen. */
|
|
88
|
+
cursor = 0
|
|
89
|
+
|
|
90
|
+
private session!: AgentSession
|
|
91
|
+
private unsubscribe: (() => void) | null = null
|
|
92
|
+
private buffer = ""
|
|
93
|
+
/** Tool calls made during the current turn, keyed for start/end matching. */
|
|
94
|
+
private activity = new Map<string, ToolActivity>()
|
|
95
|
+
/** Reasoning accumulated during the current turn. */
|
|
96
|
+
private reasoningBuffer = ""
|
|
97
|
+
private readonly emit: Emit
|
|
98
|
+
/** The directory this participant's file tools are confined to. */
|
|
99
|
+
private readonly workspaceDir: string
|
|
100
|
+
|
|
101
|
+
private constructor(persona: Persona, emit: Emit, workspaceDir: string) {
|
|
102
|
+
this.persona = persona
|
|
103
|
+
this.emit = emit
|
|
104
|
+
this.workspaceDir = workspaceDir
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static async create(
|
|
108
|
+
persona: Persona,
|
|
109
|
+
resolved: ResolvedModel,
|
|
110
|
+
emit: Emit,
|
|
111
|
+
workspaceDir: string = config.workspaceDir,
|
|
112
|
+
orchestrator?: RoomOrchestrator,
|
|
113
|
+
defaultThinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" = config.thinkingLevel,
|
|
114
|
+
allowCloud: boolean = config.allowCloud,
|
|
115
|
+
compactionReserveTokens: number = 38000,
|
|
116
|
+
): Promise<Participant> {
|
|
117
|
+
const p = new Participant(persona, emit, workspaceDir)
|
|
118
|
+
|
|
119
|
+
// Read agent memory (if it exists) — injected after the persona prompt.
|
|
120
|
+
// Capped at 4KB to avoid consuming excessive context tokens.
|
|
121
|
+
const memoryPath = join(workspaceDir, "agent_memory", `${persona.id}.md`)
|
|
122
|
+
let memoryNote = ""
|
|
123
|
+
try {
|
|
124
|
+
await access(memoryPath, constants.R_OK)
|
|
125
|
+
const raw = await readFile(memoryPath, "utf-8")
|
|
126
|
+
const content = raw.length > 4096 ? raw.slice(0, 4096) + "… (truncated)" : raw
|
|
127
|
+
memoryNote = `\nYOUR MEMORY (agent_memory/${persona.id}.md):\n${content}\n` +
|
|
128
|
+
"---\n(End of memory — updated by the scribe. After compaction, this is refreshed.)\n"
|
|
129
|
+
} catch {
|
|
130
|
+
// No memory file — fine, first run or not yet populated.
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const loader = new DefaultResourceLoader({
|
|
134
|
+
cwd: workspaceDir,
|
|
135
|
+
agentDir: getAgentDir(),
|
|
136
|
+
// Append the persona to pi's default prompt so we keep tool-usage guidance.
|
|
137
|
+
appendSystemPromptOverride: (base: string[]) => [
|
|
138
|
+
...base,
|
|
139
|
+
persona.systemPrompt,
|
|
140
|
+
workspaceNote(workspaceDir),
|
|
141
|
+
ROOM_NOTE,
|
|
142
|
+
...(memoryNote ? [memoryNote] : []),
|
|
143
|
+
],
|
|
144
|
+
})
|
|
145
|
+
await loader.reload()
|
|
146
|
+
|
|
147
|
+
// Each persona may pin its own model ("provider/id"); undefined → default.
|
|
148
|
+
const model = resolveModelRef(resolved, allowCloud, persona.model)
|
|
149
|
+
|
|
150
|
+
// Auto-compaction: trigger when context exceeds 90K tokens.
|
|
151
|
+
// reserveTokens = contextWindow - threshold. For 128K ctx: 128000 - 90000 = 38000.
|
|
152
|
+
const settings = SettingsManager.inMemory({
|
|
153
|
+
compaction: { enabled: true, reserveTokens: compactionReserveTokens },
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const { session } = await createAgentSession({
|
|
157
|
+
cwd: workspaceDir,
|
|
158
|
+
// Disable built-in file tools and supply workspace-confined replacements,
|
|
159
|
+
// gated to this persona's allowlist. Keeps all file work inside the workspace.
|
|
160
|
+
noTools: "builtin",
|
|
161
|
+
customTools: (() => {
|
|
162
|
+
const confined = buildConfinedTools(workspaceDir, persona.tools)
|
|
163
|
+
const custom = buildCustomTools(persona.tools, { orchestrator })
|
|
164
|
+
console.log(`[Participant.create] persona=${persona.id} orchestrator=${!!orchestrator} confined=${confined.length} custom=${custom.length} customNames=${custom.map(t => t.name).join(",")}`)
|
|
165
|
+
return [...confined, ...custom]
|
|
166
|
+
})(),
|
|
167
|
+
thinkingLevel: persona.thinkingLevel ?? defaultThinkingLevel,
|
|
168
|
+
resourceLoader: loader,
|
|
169
|
+
sessionManager: SessionManager.inMemory(workspaceDir),
|
|
170
|
+
settingsManager: settings,
|
|
171
|
+
authStorage: resolved.authStorage,
|
|
172
|
+
modelRegistry: resolved.modelRegistry,
|
|
173
|
+
...(model ? { model } : {}),
|
|
174
|
+
})
|
|
175
|
+
// Enable auto-compaction on the session (triggers compact() automatically
|
|
176
|
+
// when context tokens exceed the threshold after each turn).
|
|
177
|
+
session.setAutoCompactionEnabled(true)
|
|
178
|
+
// Name the session after the persona for debug visibility.
|
|
179
|
+
session.setSessionName(persona.id)
|
|
180
|
+
p.session = session
|
|
181
|
+
p.unsubscribe = session.subscribe((ev) => p.onEvent(ev))
|
|
182
|
+
return p
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private setStatus(status: ParticipantStatus): void {
|
|
186
|
+
this.status = status
|
|
187
|
+
this.emit("status", { id: this.persona.id, status })
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private onEvent(ev: AgentSessionEvent): void {
|
|
191
|
+
if (ev.type === "message_update") {
|
|
192
|
+
const me = ev.assistantMessageEvent
|
|
193
|
+
if (me.type === "text_delta") {
|
|
194
|
+
this.buffer += me.delta
|
|
195
|
+
this.emit("token", { id: this.persona.id, delta: me.delta })
|
|
196
|
+
} else if (me.type === "thinking_delta") {
|
|
197
|
+
this.reasoningBuffer += me.delta
|
|
198
|
+
this.emit("reasoning", { id: this.persona.id, delta: me.delta })
|
|
199
|
+
}
|
|
200
|
+
} else if (ev.type === "tool_execution_start") {
|
|
201
|
+
const item: ToolActivity = {
|
|
202
|
+
toolCallId: ev.toolCallId,
|
|
203
|
+
toolName: ev.toolName,
|
|
204
|
+
args: ev.args,
|
|
205
|
+
status: "running",
|
|
206
|
+
ts: Date.now(),
|
|
207
|
+
}
|
|
208
|
+
this.activity.set(ev.toolCallId, item)
|
|
209
|
+
this.setStatus("working")
|
|
210
|
+
this.emit("activity", { id: this.persona.id, item })
|
|
211
|
+
} else if (ev.type === "compaction_start") {
|
|
212
|
+
this.emit("status", { id: this.persona.id, status: "compacting", reason: ev.reason })
|
|
213
|
+
} else if (ev.type === "compaction_end") {
|
|
214
|
+
this.emit("status", { id: this.persona.id, status: "idle", compactionResult: ev.result ? { summary: ev.result.summary.slice(0, 200), tokensBefore: ev.result.tokensBefore } : undefined })
|
|
215
|
+
} else if (ev.type === "tool_execution_end") {
|
|
216
|
+
const item = this.activity.get(ev.toolCallId) ?? {
|
|
217
|
+
toolCallId: ev.toolCallId,
|
|
218
|
+
toolName: ev.toolName,
|
|
219
|
+
status: "running" as const,
|
|
220
|
+
ts: Date.now(),
|
|
221
|
+
}
|
|
222
|
+
item.status = ev.isError ? "error" : "ok"
|
|
223
|
+
item.result = clip(ev.result)
|
|
224
|
+
this.activity.set(ev.toolCallId, item)
|
|
225
|
+
this.setStatus("active")
|
|
226
|
+
this.emit("activity", { id: this.persona.id, item })
|
|
227
|
+
} else if (ev.type === "auto_retry_start") {
|
|
228
|
+
this.emit("status", {
|
|
229
|
+
id: this.persona.id,
|
|
230
|
+
status: "retrying",
|
|
231
|
+
retry: {
|
|
232
|
+
attempt: ev.attempt,
|
|
233
|
+
maxAttempts: ev.maxAttempts,
|
|
234
|
+
delayMs: ev.delayMs,
|
|
235
|
+
errorMessage: ev.errorMessage,
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
} else if (ev.type === "auto_retry_end") {
|
|
239
|
+
this.emit("status", {
|
|
240
|
+
id: this.persona.id,
|
|
241
|
+
status: ev.success ? "active" : "idle",
|
|
242
|
+
retryResult: {
|
|
243
|
+
success: ev.success,
|
|
244
|
+
attempt: ev.attempt,
|
|
245
|
+
finalError: ev.finalError,
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Run one turn with the given prompt text. Optionally pass image paths
|
|
252
|
+
* (workspace-relative, e.g. "media/abc.png") for vision support. */
|
|
253
|
+
async run(promptText: string, imagePaths?: string[]): Promise<TurnResult> {
|
|
254
|
+
this.buffer = ""
|
|
255
|
+
this.reasoningBuffer = ""
|
|
256
|
+
this.activity.clear()
|
|
257
|
+
this.setStatus("active")
|
|
258
|
+
try {
|
|
259
|
+
const images = await this.resolveImages(imagePaths)
|
|
260
|
+
await this.session.prompt(promptText, images.length > 0 ? { images } : undefined)
|
|
261
|
+
const result: TurnResult = {
|
|
262
|
+
text: this.buffer.trim(),
|
|
263
|
+
activity: [...this.activity.values()],
|
|
264
|
+
}
|
|
265
|
+
if (this.reasoningBuffer.trim()) {
|
|
266
|
+
result.reasoning = this.reasoningBuffer.trim()
|
|
267
|
+
}
|
|
268
|
+
// Check if the agent called ask_user — extract the question from the tool args.
|
|
269
|
+
for (const act of result.activity) {
|
|
270
|
+
if (act.toolName === "ask_user" && act.status === "ok") {
|
|
271
|
+
const args = act.args as Record<string, unknown> | undefined
|
|
272
|
+
const q = typeof args?.question === "string" ? args.question : undefined
|
|
273
|
+
if (q) {
|
|
274
|
+
result.question = q
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return result
|
|
280
|
+
} finally {
|
|
281
|
+
this.setStatus("idle")
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Resolve workspace-relative image paths to ImageContent objects for the LLM. */
|
|
286
|
+
private async resolveImages(paths?: string[]): Promise<
|
|
287
|
+
Array<{ type: "image"; data: string; mimeType: string }>
|
|
288
|
+
> {
|
|
289
|
+
if (!paths) return []
|
|
290
|
+
const images: Array<{ type: "image"; data: string; mimeType: string }> = []
|
|
291
|
+
for (const relPath of paths) {
|
|
292
|
+
try {
|
|
293
|
+
const fullPath = join(this.workspaceDir, relPath)
|
|
294
|
+
const buf = readFileSync(fullPath)
|
|
295
|
+
const ext = relPath.split(".").pop()?.toLowerCase()
|
|
296
|
+
const mimeType = ext === "png" ? "image/png" : ext === "gif" ? "image/gif" : ext === "webp" ? "image/webp" : "image/jpeg"
|
|
297
|
+
images.push({ type: "image", data: buf.toString("base64"), mimeType })
|
|
298
|
+
} catch (err) {
|
|
299
|
+
// Skip images that fail to read.
|
|
300
|
+
console.warn(`[participant] image read failed: ${relPath} — ${err instanceof Error ? err.message : String(err)}`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return images
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async abort(): Promise<void> {
|
|
307
|
+
await this.session.abort()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Compact the agent's session context (summarize old turns to free tokens). */
|
|
311
|
+
async compact(): Promise<{ summary: string; tokensBefore: number }> {
|
|
312
|
+
const result = await this.session.compact(this.persona.compactionInstructions)
|
|
313
|
+
return { summary: result.summary, tokensBefore: result.tokensBefore }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Whether the agent is currently compacting. */
|
|
317
|
+
get isCompacting(): boolean {
|
|
318
|
+
return this.session.isCompacting
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Get the agent's current context usage. Undefined if the session has no usage info yet. */
|
|
322
|
+
getContextUsage(): ReturnType<AgentSession["getContextUsage"]> {
|
|
323
|
+
return this.session.getContextUsage()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Set thinking level in-place — no session recreation needed. */
|
|
327
|
+
async setThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): Promise<void> {
|
|
328
|
+
await this.session.setThinkingLevel(level)
|
|
329
|
+
this.persona.thinkingLevel = level
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Get the thinking levels supported by the current model. */
|
|
333
|
+
getAvailableThinkingLevels(): string[] {
|
|
334
|
+
return this.session.getAvailableThinkingLevels() ?? []
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Get session stats — token counts, cache split, turn counts. */
|
|
338
|
+
getSessionStats(): ReturnType<AgentSession["getSessionStats"]> | undefined {
|
|
339
|
+
return this.session.getSessionStats()
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Get the last assistant response text (convenience method). */
|
|
343
|
+
getLastAssistantText(): string | undefined {
|
|
344
|
+
return this.session.getLastAssistantText()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Export the agent's session to a self-contained HTML file. Returns the file path. */
|
|
348
|
+
async exportToHtml(): Promise<string> {
|
|
349
|
+
return await this.session.exportToHtml()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Export the agent's session as JSONL (one JSON object per line). Returns the file path. */
|
|
353
|
+
exportToJsonl(): string {
|
|
354
|
+
return this.session.exportToJsonl()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Queue a steering message while the agent is running.
|
|
358
|
+
* Throws if the session is not currently streaming — can't steer an idle agent. */
|
|
359
|
+
async steer(text: string): Promise<void> {
|
|
360
|
+
if (!this.session.isStreaming) {
|
|
361
|
+
throw new Error(`participant "${this.persona.id}" is not running — cannot steer`)
|
|
362
|
+
}
|
|
363
|
+
await this.session.steer(text)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Queue a follow-up message — guaranteed to be the next thing the agent
|
|
367
|
+
* processes. Used for self-chaining (e.g., delivering an ask_user answer
|
|
368
|
+
* directly to the agent that asked it). */
|
|
369
|
+
async followUp(text: string, imagePaths?: string[]): Promise<TurnResult> {
|
|
370
|
+
this.buffer = ""
|
|
371
|
+
this.reasoningBuffer = ""
|
|
372
|
+
this.activity.clear()
|
|
373
|
+
this.setStatus("active")
|
|
374
|
+
try {
|
|
375
|
+
const images = await this.resolveImages(imagePaths)
|
|
376
|
+
// session.followUp() only delivers when the agent is currently streaming.
|
|
377
|
+
// After ask_user (terminate=true) the session is idle — the followUp message
|
|
378
|
+
// would be queued but never processed. Use prompt() for idle sessions instead.
|
|
379
|
+
if (!this.session.isStreaming) {
|
|
380
|
+
await this.session.prompt(text, images.length > 0 ? { images } : undefined)
|
|
381
|
+
} else {
|
|
382
|
+
await this.session.followUp(text, images.length > 0 ? images : undefined)
|
|
383
|
+
}
|
|
384
|
+
const result: TurnResult = {
|
|
385
|
+
text: this.buffer.trim(),
|
|
386
|
+
activity: [...this.activity.values()],
|
|
387
|
+
}
|
|
388
|
+
if (this.reasoningBuffer.trim()) {
|
|
389
|
+
result.reasoning = this.reasoningBuffer.trim()
|
|
390
|
+
}
|
|
391
|
+
// Check for ask_user in the follow-up result too.
|
|
392
|
+
for (const act of result.activity) {
|
|
393
|
+
if (act.toolName === "ask_user" && act.status === "ok") {
|
|
394
|
+
const args = act.args as Record<string, unknown> | undefined
|
|
395
|
+
const q = typeof args?.question === "string" ? args.question : undefined
|
|
396
|
+
if (q) {
|
|
397
|
+
result.question = q
|
|
398
|
+
break
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return result
|
|
403
|
+
} finally {
|
|
404
|
+
this.setStatus("idle")
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Inject a custom message into the agent's context (invisible in the transcript).
|
|
409
|
+
* Used for structured signals like work receipts between agents. */
|
|
410
|
+
async sendCustomMessage(message: {
|
|
411
|
+
customType: string
|
|
412
|
+
content: string
|
|
413
|
+
display: boolean
|
|
414
|
+
}, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void> {
|
|
415
|
+
await this.session.sendCustomMessage(message, options)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
dispose(): void {
|
|
419
|
+
this.unsubscribe?.()
|
|
420
|
+
this.unsubscribe = null
|
|
421
|
+
this.session.dispose()
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Shared path confinement guard used by both ConversationStore and sandbox-tools.
|
|
2
|
+
// Prevents path traversal by verifying that the resolved target path lives
|
|
3
|
+
// inside the allowed root directory.
|
|
4
|
+
|
|
5
|
+
import { isAbsolute, relative, resolve } from "node:path"
|
|
6
|
+
|
|
7
|
+
/** Throw if `target` resolves outside `root`. */
|
|
8
|
+
export function assertInside(root: string, target: string): void {
|
|
9
|
+
const rel = relative(root, resolve(target))
|
|
10
|
+
if (rel !== "" && (rel === ".." || rel.startsWith(`..${"/"}`) || rel.startsWith("..\\") || isAbsolute(rel))) {
|
|
11
|
+
throw new Error(`Permission denied: "${target}" is outside the allowed directory.`)
|
|
12
|
+
}
|
|
13
|
+
}
|