@yeaft/webchat-agent 1.0.602 → 1.0.605
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +128 -118
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/person/capabilities.js +1 -1
- package/yeaft/person/child-provider.js +156 -0
- package/yeaft/person/native-tools.js +37 -11
- package/yeaft/person/runtime.js +106 -16
- package/yeaft/person/service.js +9 -6
- package/yeaft/person/task-host.js +542 -0
- package/yeaft/sub-agent/runner.js +6 -1
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import { mkdirSync, realpathSync, readFileSync, writeFileSync, renameSync, readdirSync, existsSync, lstatSync } from 'node:fs';
|
|
2
|
+
import { resolve, join, relative, isAbsolute, sep } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { TaskManager } from '../tasks/manager.js';
|
|
5
|
+
import { TASK_RESULT_DELIVERY, isTerminalTaskStatus } from '../tasks/store.js';
|
|
6
|
+
import { createFullRegistry } from '../tools/index.js';
|
|
7
|
+
import { ToolRegistry, normalizeToolOutput } from '../tools/registry.js';
|
|
8
|
+
import { SubAgentToolRegistry } from '../sub-agent/execution-control.js';
|
|
9
|
+
import { agentBelongsToScope, getAgentRegistry } from '../tools/agent.js';
|
|
10
|
+
import closeAgent from '../tools/close-agent.js';
|
|
11
|
+
import { consumeNotificationForAgent } from '../sub-agent/notifications.js';
|
|
12
|
+
import { isTerminalAgentStatus } from '../sub-agent/status.js';
|
|
13
|
+
import { NullTrace } from '../debug-trace.js';
|
|
14
|
+
import { loadConfig } from '../config.js';
|
|
15
|
+
import { digest } from './contracts.js';
|
|
16
|
+
|
|
17
|
+
// Deliberately not the full Session registry: no transcript search, routing,
|
|
18
|
+
// interactive UI, MCP, Work Center, or nested orchestration authority.
|
|
19
|
+
export const PERSON_TASK_TOOL_IDS = Object.freeze([
|
|
20
|
+
'FileRead', 'FileWrite', 'FileEdit', 'Glob', 'Grep', 'ListDir', 'DiskUsage',
|
|
21
|
+
'ApplyPatch', 'GitRead', 'Bash', 'WebSearch', 'WebFetch', 'Skill', 'NotebookEdit',
|
|
22
|
+
'SpawnAgent', 'ListAgents', 'WaitAgent', 'PromptAgent', 'CloseAgent', 'UpdateAgent',
|
|
23
|
+
'ListTasks', 'ReadTaskLog', 'WaitTask', 'CancelTask',
|
|
24
|
+
'DiscoverTools', // child Engine schema activation; Person itself uses its catalog
|
|
25
|
+
]);
|
|
26
|
+
const nativeRegistry = createFullRegistry();
|
|
27
|
+
const liveHosts = new Map();
|
|
28
|
+
const pause = () => new Promise(resolve => setTimeout(resolve, 10));
|
|
29
|
+
const clone = value => JSON.parse(JSON.stringify(value));
|
|
30
|
+
const denied = () => Object.assign(new Error('Person task namespace access denied'), { code: 'TASK_SCOPE_DENIED' });
|
|
31
|
+
|
|
32
|
+
function identity(options) {
|
|
33
|
+
for (const key of ['yeaftDir', 'ownerId', 'personId']) {
|
|
34
|
+
if (typeof options[key] !== 'string' || !options[key].trim() || options[key].includes('\0')) throw denied();
|
|
35
|
+
}
|
|
36
|
+
const namespace = options.namespace ?? 'default';
|
|
37
|
+
if (typeof namespace !== 'string' || !namespace.trim() || namespace.includes('\0')) throw denied();
|
|
38
|
+
mkdirSync(resolve(options.yeaftDir), { recursive: true });
|
|
39
|
+
const yeaftDir = realpathSync(resolve(options.yeaftDir));
|
|
40
|
+
const scope = { yeaftDir, namespace, ownerId: options.ownerId, personId: options.personId };
|
|
41
|
+
return { ...scope, key: digest(scope) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Canonical instance aliases are supported; links below that root must never
|
|
45
|
+
// turn two scoped task roots into the same storage. Local code is trusted not
|
|
46
|
+
// to race filesystem mutations; this is not an OS filesystem sandbox.
|
|
47
|
+
function privatePath(root, path) {
|
|
48
|
+
const child = relative(root, resolve(path));
|
|
49
|
+
if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) throw denied();
|
|
50
|
+
let current = root;
|
|
51
|
+
for (const part of child.split(sep).filter(Boolean)) {
|
|
52
|
+
current = join(current, part);
|
|
53
|
+
try { if (lstatSync(current).isSymbolicLink()) throw denied(); }
|
|
54
|
+
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
55
|
+
}
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function atomicJSON(path, value) {
|
|
60
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
61
|
+
writeFileSync(temporary, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
|
62
|
+
renameSync(temporary, path);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function jsonFiles(directory) {
|
|
66
|
+
if (!existsSync(directory)) return [];
|
|
67
|
+
return readdirSync(directory).filter(name => name.endsWith('.json')).flatMap(name => {
|
|
68
|
+
try { return [JSON.parse(readFileSync(join(directory, name), 'utf8'))]; } catch { return []; }
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One live host per canonical instance / deployment namespace / owner / Person.
|
|
73
|
+
* It outlives episodes. Use close() only for explicit shutdown, never on commit.
|
|
74
|
+
* TaskStore's Session-shaped directories are private task storage, not Sessions;
|
|
75
|
+
* neither a conversation store nor a Session manifest is created here.
|
|
76
|
+
*/
|
|
77
|
+
class ScopedPersonTaskHost {
|
|
78
|
+
#manager;
|
|
79
|
+
#agents = new Map();
|
|
80
|
+
#calls = new Set();
|
|
81
|
+
#stopping = null;
|
|
82
|
+
#stopAll = false;
|
|
83
|
+
#episodes = new Set();
|
|
84
|
+
#cancelledEpisodes = new Set();
|
|
85
|
+
#closed = false;
|
|
86
|
+
#closePromise = null;
|
|
87
|
+
#options;
|
|
88
|
+
#agentEpisodes = new Map();
|
|
89
|
+
#instrumentedAgents = new WeakSet();
|
|
90
|
+
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
this.scope = Object.freeze(identity(options));
|
|
93
|
+
if (liveHosts.has(this.scope.key)) throw Object.assign(new Error('Person task host is already live'), { code: 'TASK_HOST_BUSY' });
|
|
94
|
+
this.#options = options;
|
|
95
|
+
this.yeaftDir = this.scope.yeaftDir;
|
|
96
|
+
this.dataRoot = join(this.yeaftDir, 'person', 'tasks', this.scope.key);
|
|
97
|
+
this.sessionId = `person-task-${this.scope.key}`;
|
|
98
|
+
this.parentVpId = `person-${this.scope.key}`;
|
|
99
|
+
this.threadId = `person-thread-${this.scope.key}`;
|
|
100
|
+
this.agentScope = Object.freeze({ sessionId: this.sessionId, parentVpId: this.parentVpId, parentThreadId: this.threadId });
|
|
101
|
+
for (const directory of [this.dataRoot, join(this.dataRoot, 'completions'),
|
|
102
|
+
join(this.dataRoot, 'tool-results'), join(this.dataRoot, 'sub-agents'),
|
|
103
|
+
join(this.dataRoot, 'tasks', 'sessions', this.sessionId)]) {
|
|
104
|
+
privatePath(this.yeaftDir, directory);
|
|
105
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
106
|
+
}
|
|
107
|
+
privatePath(this.yeaftDir, join(this.dataRoot, 'agents.json'));
|
|
108
|
+
this.#restoreAgents();
|
|
109
|
+
this.#manager = new TaskManager({ yeaftDir: this.dataRoot, cancelEscalationMs: options.cancelEscalationMs });
|
|
110
|
+
this.taskManager = this.#scopedManager();
|
|
111
|
+
// Drain startup orphan evidence only after the scoped manager is installed.
|
|
112
|
+
this.#manager.setEventSink(event => this.#taskEvent(event));
|
|
113
|
+
liveHosts.set(this.scope.key, this);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#assertOpen(episodeId = null) {
|
|
117
|
+
if (this.#closed) throw Object.assign(new Error('Person task host is closed'), { code: 'TASK_HOST_CLOSED' });
|
|
118
|
+
if (episodeId && this.#cancelledEpisodes.has(episodeId)) {
|
|
119
|
+
throw Object.assign(new Error('Person task episode is cancelled'), { code: 'TASK_EPISODE_CANCELLED' });
|
|
120
|
+
}
|
|
121
|
+
if (this.#stopAll) throw Object.assign(new Error('Person task host is cancelling'), { code: 'TASK_HOST_CLOSED' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#assertScope(ctx = {}, { child = false } = {}) {
|
|
125
|
+
for (const key of ['ownerId', 'personId', 'namespace']) {
|
|
126
|
+
if (ctx[key] != null && ctx[key] !== this.scope[key]) throw denied();
|
|
127
|
+
}
|
|
128
|
+
if (ctx.yeaftDir != null && realpathSync(resolve(ctx.yeaftDir)) !== this.yeaftDir) throw denied();
|
|
129
|
+
if (ctx.sessionId != null && ctx.sessionId !== this.sessionId) throw denied();
|
|
130
|
+
const agent = child ? getAgentRegistry().get(ctx.threadId) : null;
|
|
131
|
+
const ownChild = agent?.__driverStarted && agentBelongsToScope(agent, this.agentScope);
|
|
132
|
+
if (ctx.threadId != null && ctx.threadId !== this.threadId && !ownChild) throw denied();
|
|
133
|
+
for (const key of ['currentVpId', 'senderVpId', 'parentVpId']) {
|
|
134
|
+
if (ctx[key] != null && ctx[key] !== this.parentVpId && !(ownChild && ctx[key] === agent.id)) throw denied();
|
|
135
|
+
}
|
|
136
|
+
if (ctx.parentThreadId != null && ctx.parentThreadId !== this.threadId) throw denied();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#session(sessionId) {
|
|
140
|
+
if (sessionId != null && sessionId !== this.sessionId) throw denied();
|
|
141
|
+
return this.sessionId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#owner(ownerVpId) {
|
|
145
|
+
if (ownerVpId != null && ownerVpId !== this.parentVpId) throw denied();
|
|
146
|
+
return this.parentVpId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#privateLog(path) {
|
|
150
|
+
const child = relative(this.dataRoot, resolve(path));
|
|
151
|
+
if (child === '..' || child.startsWith(`..${sep}`) || isAbsolute(child)) throw denied();
|
|
152
|
+
return privatePath(this.yeaftDir, path);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#scopedManager(episodeId = null, signal = null) {
|
|
156
|
+
const manager = this.#manager;
|
|
157
|
+
return Object.freeze({
|
|
158
|
+
startShellTask: (input = {}) => {
|
|
159
|
+
this.#assertOpen(episodeId);
|
|
160
|
+
signal?.throwIfAborted();
|
|
161
|
+
return manager.startShellTask({ ...input, source: { ...input.source, episodeId }, sessionId: this.#session(input.sessionId), ownerVpId: this.#owner(input.ownerVpId), resultDelivery: TASK_RESULT_DELIVERY.STATUS_ONLY });
|
|
162
|
+
},
|
|
163
|
+
startTask: (input = {}) => {
|
|
164
|
+
this.#assertOpen(episodeId);
|
|
165
|
+
signal?.throwIfAborted();
|
|
166
|
+
if (input.logPath) this.#privateLog(input.logPath);
|
|
167
|
+
return manager.startTask({ ...input, source: { ...input.source, episodeId }, sessionId: this.#session(input.sessionId), ownerVpId: this.#owner(input.ownerVpId), resultDelivery: TASK_RESULT_DELIVERY.STATUS_ONLY });
|
|
168
|
+
},
|
|
169
|
+
listActiveTasks: (sessionId, ownerVpId) => manager.listActiveTasks(this.#session(sessionId), this.#owner(ownerVpId)),
|
|
170
|
+
getTask: (sessionId, taskId, ownerVpId) => manager.getTask(this.#session(sessionId), taskId, this.#owner(ownerVpId)),
|
|
171
|
+
readTaskLog: (sessionId, taskId, opts, ownerVpId) => manager.readTaskLog(this.#session(sessionId), taskId, opts, this.#owner(ownerVpId)),
|
|
172
|
+
waitForTask: (sessionId, taskId, opts = {}) => manager.waitForTask(this.#session(sessionId), taskId, { ...opts, ownerVpId: this.#owner(opts.ownerVpId) }),
|
|
173
|
+
cancelTask: (sessionId, taskId, ownerVpId) => manager.cancelTask(this.#session(sessionId), taskId, this.#owner(ownerVpId)),
|
|
174
|
+
completeTask: (sessionId, taskId, opts) => manager.completeTask(this.#session(sessionId), taskId, opts),
|
|
175
|
+
setTaskLogPath: (sessionId, taskId, path) => manager.setTaskLogPath(this.#session(sessionId), taskId, this.#privateLog(path)),
|
|
176
|
+
refreshTaskLog: (sessionId, taskId) => manager.refreshTaskLog(this.#session(sessionId), taskId),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#persistAgents() {
|
|
181
|
+
for (const agent of getAgentRegistry().values()) {
|
|
182
|
+
if (!agentBelongsToScope(agent, this.agentScope)) continue;
|
|
183
|
+
const fields = ['id', 'name', 'task', 'mission', 'persona', 'budget', 'allowTools', 'status', 'result', 'lastResult',
|
|
184
|
+
'partial_output', 'error', 'outputFile', 'taskId', 'usage', 'createdAt', 'liveness', 'diagnostics', 'finalReport',
|
|
185
|
+
'parentSessionId', 'parentVpId', 'parentThreadId', 'recoveryStatus', 'personEpisodeId'];
|
|
186
|
+
this.#agents.set(agent.id, Object.fromEntries(fields.filter(key => agent[key] !== undefined).map(key => [key, clone(agent[key])])));
|
|
187
|
+
}
|
|
188
|
+
atomicJSON(join(this.dataRoot, 'agents.json'), { scope: this.scope, agents: [...this.#agents.values()] });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#restoreAgents() {
|
|
192
|
+
const path = join(this.dataRoot, 'agents.json');
|
|
193
|
+
if (!existsSync(path)) return;
|
|
194
|
+
const saved = JSON.parse(readFileSync(path, 'utf8'));
|
|
195
|
+
if (digest(saved.scope) !== digest(this.scope)) throw denied();
|
|
196
|
+
for (const record of saved.agents ?? []) {
|
|
197
|
+
if (!agentBelongsToScope(record, this.agentScope)) throw denied();
|
|
198
|
+
if (!isTerminalAgentStatus(record.status)) {
|
|
199
|
+
// Native Agent status has no orphaned state; make it terminal failed,
|
|
200
|
+
// retain the precise recovery fact, and let TaskManager orphan its task.
|
|
201
|
+
record.status = 'failed';
|
|
202
|
+
record.recoveryStatus = 'orphaned';
|
|
203
|
+
record.error = 'Agent restarted while child was running; child control was lost.';
|
|
204
|
+
}
|
|
205
|
+
const existing = getAgentRegistry().get(record.id);
|
|
206
|
+
if (existing && !agentBelongsToScope(existing, this.agentScope)) throw denied();
|
|
207
|
+
if (existing?.__driverStarted) throw Object.assign(new Error('Child driver is still live'), { code: 'TASK_HOST_BUSY' });
|
|
208
|
+
this.#agents.set(record.id, record);
|
|
209
|
+
this.#agentEpisodes.set(record.id, record.personEpisodeId ?? null);
|
|
210
|
+
getAgentRegistry().set(record.id, { ...record, messages: [], abortController: new AbortController(), __driverStarted: false });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#taskEvent(event) {
|
|
215
|
+
const task = event.task;
|
|
216
|
+
if (!task || task.sessionId !== this.sessionId || task.ownerVpId !== this.parentVpId) return;
|
|
217
|
+
this.#persistAgents();
|
|
218
|
+
if (event.event === 'completed') {
|
|
219
|
+
const completion = { kind: 'person-task-completion', namespace: this.scope.key, task: clone(task),
|
|
220
|
+
sourceRef: `person-task:${this.scope.key}:${task.id}:${task.status}:${task.updatedAt}`,
|
|
221
|
+
notice: 'External task evidence only. No Person episode was scheduled.' };
|
|
222
|
+
atomicJSON(join(this.dataRoot, 'completions', `${task.id}.json`), completion);
|
|
223
|
+
// Observability only: never register model re-entry or call runtime.start.
|
|
224
|
+
Promise.resolve().then(() => this.#options.onCompletion?.(completion)).catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
allowedToolIds(parentToolRegistry = nativeRegistry) {
|
|
229
|
+
const config = this.#options.config ?? loadConfig({ dir: this.yeaftDir });
|
|
230
|
+
return PERSON_TASK_TOOL_IDS.filter(id => (!this.#options.allowedToolIds || this.#options.allowedToolIds.includes(id)) && parentToolRegistry.isAllowed(id, { plugins: config.plugins }));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Attach to an explicit episode's tool context. Provider.catalog is the
|
|
234
|
+
* permitted episode catalog, not the larger UI availableModels inventory.
|
|
235
|
+
* Returned deps are built afresh, never inherited from arbitrary Session ctx.
|
|
236
|
+
* Existing child drivers keep their own provider snapshot and abort signal.
|
|
237
|
+
*/
|
|
238
|
+
async attach(ctx = {}, { provider = this.#options.provider, selection = this.#options.selection } = {}) {
|
|
239
|
+
this.#assertOpen(ctx.episodeId);
|
|
240
|
+
this.#assertScope(ctx);
|
|
241
|
+
if (ctx.episodeId) this.#episodes.add(ctx.episodeId);
|
|
242
|
+
const config = this.#options.config ?? loadConfig({ dir: this.yeaftDir });
|
|
243
|
+
let child;
|
|
244
|
+
if (provider) {
|
|
245
|
+
const factory = this.#options.childProviderFactory ?? (await import('./child-provider.js')).createPersonChildProvider;
|
|
246
|
+
child = await factory(provider, selection ?? provider.defaultSelection, config);
|
|
247
|
+
if (!child?.adapter || !child?.config) throw new Error('Person child provider requires adapter and config');
|
|
248
|
+
}
|
|
249
|
+
this.#assertOpen(ctx.episodeId);
|
|
250
|
+
const registry = new ToolRegistry();
|
|
251
|
+
const dispatch = new ToolRegistry();
|
|
252
|
+
const parentToolRegistry = ctx.parentToolRegistry ?? nativeRegistry;
|
|
253
|
+
const manager = ctx.episodeId ? this.#scopedManager(ctx.episodeId) : this.taskManager;
|
|
254
|
+
const deps = {
|
|
255
|
+
adapter: child?.adapter ?? null, config: child?.config ?? config, trace: new NullTrace(),
|
|
256
|
+
yeaftDir: this.yeaftDir, subAgentLogDir: join(this.dataRoot, 'sub-agents'),
|
|
257
|
+
parentName: ctx.personName ?? this.scope.personId, parentVpId: this.parentVpId, childVpId: this.parentVpId,
|
|
258
|
+
parentSessionId: this.sessionId, parentThreadId: this.threadId,
|
|
259
|
+
parentVpPersona: ctx.vpPersona ?? null, parentToolRegistry: registry, taskManager: manager,
|
|
260
|
+
effortDecision: ctx.effortDecision ?? null, episodeId: ctx.episodeId ?? null,
|
|
261
|
+
skillManager: ctx.skillManager ?? null, language: config.language ?? 'en',
|
|
262
|
+
onEvent: agentId => { this.#instrumentChild(getAgentRegistry().get(agentId), deps); this.#persistAgents(); },
|
|
263
|
+
};
|
|
264
|
+
for (const id of this.allowedToolIds(parentToolRegistry)) {
|
|
265
|
+
const tool = parentToolRegistry.get(id);
|
|
266
|
+
registry.register(tool);
|
|
267
|
+
dispatch.register({ ...tool, execute: (args, caller = {}) => this.#execute(tool, args, caller, deps) });
|
|
268
|
+
}
|
|
269
|
+
// Exposure keeps the parent's exact ToolDefs; dispatch alone is wrapped.
|
|
270
|
+
registry.execute = (name, args, caller) => dispatch.execute(name, args, caller);
|
|
271
|
+
return { ...ctx, config, ownerId: this.scope.ownerId, personId: this.scope.personId, namespace: this.scope.namespace,
|
|
272
|
+
yeaftDir: this.yeaftDir, cwd: ctx.cwd ?? resolve(this.#options.workDir ?? process.cwd()),
|
|
273
|
+
sessionId: this.sessionId, currentVpId: this.parentVpId, senderVpId: this.parentVpId,
|
|
274
|
+
parentVpId: this.parentVpId, threadId: this.threadId, taskManager: manager, parentEngineDeps: deps,
|
|
275
|
+
nativeRegistry: registry, registerAsyncTask: undefined, asyncTaskCoordinator: undefined,
|
|
276
|
+
conversationStore: null, taskHost: this };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async #execute(tool, args = {}, caller, deps) {
|
|
280
|
+
this.#assertOpen(deps.episodeId);
|
|
281
|
+
this.#assertScope(caller, { child: true });
|
|
282
|
+
if (args.sessionId != null && args.sessionId !== this.sessionId) throw denied();
|
|
283
|
+
if (!this.allowedToolIds().includes(tool.name)) return JSON.stringify({ error: 'Native tool disabled', errorEffect: 'none' });
|
|
284
|
+
if (tool.name === 'SpawnAgent' && !deps.adapter) return JSON.stringify({ error: 'Person child provider unavailable', errorEffect: 'none' });
|
|
285
|
+
const controller = new AbortController();
|
|
286
|
+
const abort = () => controller.abort(caller.signal.reason);
|
|
287
|
+
caller.signal?.addEventListener('abort', abort, { once: true });
|
|
288
|
+
if (caller.signal?.aborted) abort();
|
|
289
|
+
const context = { ...caller, yeaftDir: this.yeaftDir, sessionId: this.sessionId,
|
|
290
|
+
currentVpId: this.parentVpId, senderVpId: this.parentVpId, threadId: caller.threadId ?? this.threadId,
|
|
291
|
+
cwd: caller.cwd ?? resolve(this.#options.workDir ?? process.cwd()),
|
|
292
|
+
taskManager: this.#scopedManager(deps.episodeId, controller.signal), parentEngineDeps: deps, signal: controller.signal,
|
|
293
|
+
registerAsyncTask: undefined, asyncTaskCoordinator: undefined, conversationStore: null };
|
|
294
|
+
const job = { id: randomUUID(), controller, promise: null, episodeId: deps.episodeId,
|
|
295
|
+
childId: getAgentRegistry().has(caller.threadId) ? caller.threadId : null, tool: tool.name };
|
|
296
|
+
job.promise = Promise.resolve().then(async () => {
|
|
297
|
+
controller.signal.throwIfAborted();
|
|
298
|
+
let output;
|
|
299
|
+
const taskToCancel = tool.name === 'CancelTask' && args.taskId
|
|
300
|
+
? this.taskManager.getTask(this.sessionId, args.taskId) : null;
|
|
301
|
+
if (taskToCancel?.kind === 'sub_agent') {
|
|
302
|
+
const agent = getAgentRegistry().get(taskToCancel.runtime?.subAgentId);
|
|
303
|
+
if (agent?.id === job.childId) {
|
|
304
|
+
// The current tool call belongs to that driver's query. Joining it
|
|
305
|
+
// here would await ourselves forever (even after registry timeout).
|
|
306
|
+
output = JSON.stringify({ ok: false, error: 'A child cannot synchronously cancel its own task; ask the parent to cancel it.', errorEffect: 'none' });
|
|
307
|
+
} else if (!agent || !agentBelongsToScope(agent, this.agentScope)) {
|
|
308
|
+
output = JSON.stringify({ ok: false, error: 'Unable to cancel task: no live child handle.' });
|
|
309
|
+
} else {
|
|
310
|
+
await closeAgent.execute({ agent_id: agent.id }, context);
|
|
311
|
+
await this.#joinAgent(agent);
|
|
312
|
+
output = JSON.stringify({ ok: true, task: this.taskManager.getTask(this.sessionId, args.taskId), pending: false });
|
|
313
|
+
}
|
|
314
|
+
} else {
|
|
315
|
+
output = await tool.execute(args, context);
|
|
316
|
+
if (tool.name === 'SpawnAgent') {
|
|
317
|
+
const result = JSON.parse(output);
|
|
318
|
+
this.#instrumentChild(getAgentRegistry().get(result.agentId), deps);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
this.#persistAgents();
|
|
322
|
+
if (tool.name === 'CloseAgent') await this.#joinAgent(getAgentRegistry().get(args.agent_id));
|
|
323
|
+
if (tool.name === 'CancelTask') {
|
|
324
|
+
const result = JSON.parse(output);
|
|
325
|
+
if (result.ok && result.task) {
|
|
326
|
+
if (!isTerminalTaskStatus(result.task.status)) result.task = await this.#joinShell(result.task.id);
|
|
327
|
+
result.pending = false;
|
|
328
|
+
output = JSON.stringify(result);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
this.#archiveToolResult(job, output);
|
|
332
|
+
return output;
|
|
333
|
+
}).catch(error => { this.#archiveToolResult(job, null, error); throw error; });
|
|
334
|
+
this.#calls.add(job);
|
|
335
|
+
try { return await job.promise; }
|
|
336
|
+
finally { this.#calls.delete(job); caller.signal?.removeEventListener('abort', abort); }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#archiveToolResult(job, output, error) {
|
|
340
|
+
const text = error ? String(error.message || error) : normalizeToolOutput(output);
|
|
341
|
+
const result = { id: job.id, tool: job.tool, episodeId: job.episodeId, childId: job.childId,
|
|
342
|
+
updatedAt: new Date().toISOString(), ok: !error, aborted: job.controller.signal.aborted,
|
|
343
|
+
output: text, sourceRef: `person-tool:${this.scope.key}:${job.id}` };
|
|
344
|
+
atomicJSON(join(this.dataRoot, 'tool-results', `${job.id}.json`), result);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#instrumentChild(agent, deps) {
|
|
348
|
+
if (!agent || !agentBelongsToScope(agent, this.agentScope)) return;
|
|
349
|
+
agent.personEpisodeId = deps.episodeId;
|
|
350
|
+
this.#agentEpisodes.set(agent.id, deps.episodeId);
|
|
351
|
+
const registry = agent.subEngine?.toolRegistry;
|
|
352
|
+
if (!registry || this.#instrumentedAgents.has(agent)) return;
|
|
353
|
+
this.#instrumentedAgents.add(agent);
|
|
354
|
+
const wrappers = new WeakMap();
|
|
355
|
+
const dispatch = new SubAgentToolRegistry({ agent, allows: definition => {
|
|
356
|
+
const original = registry.get(definition.name);
|
|
357
|
+
return original && wrappers.get(original) === definition && registry.allows(original);
|
|
358
|
+
} });
|
|
359
|
+
// Never mutate a canonical ToolDef or replace its child exposure. Retain
|
|
360
|
+
// native child authorization/accounting but wrap actual execute promises
|
|
361
|
+
// below its timeout race so shutdown also joins detached after-effects.
|
|
362
|
+
registry.execute = (name, args, caller = {}) => {
|
|
363
|
+
const tool = registry.get(name);
|
|
364
|
+
if (!tool || !registry.allows(tool)) return Promise.reject(new Error(`Unknown or disallowed child tool: ${name}`));
|
|
365
|
+
if (!wrappers.has(tool)) wrappers.set(tool, { ...tool, execute: (input, context) => this.#execute(tool, input, context, deps) });
|
|
366
|
+
dispatch.register(wrappers.get(tool));
|
|
367
|
+
return dispatch.execute(name, args, caller);
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
context({ episode, provider, selection, effortDecision, parentToolRegistry, ...ctx } = {}) {
|
|
372
|
+
if (!episode?.id || episode.ownerId !== this.scope.ownerId || episode.personId !== this.scope.personId
|
|
373
|
+
|| (episode.namespace != null && episode.namespace !== this.scope.namespace)) throw denied();
|
|
374
|
+
return this.attach({ ...ctx, ownerId: episode.ownerId, personId: episode.personId,
|
|
375
|
+
namespace: episode.namespace, episodeId: episode.id, effortDecision, parentToolRegistry }, { provider, selection });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
snapshot(ownerId) {
|
|
379
|
+
if (ownerId !== this.scope.ownerId) throw denied();
|
|
380
|
+
return this.evidence();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Convenience for non-Registry embedding; returns the untruncated native output. */
|
|
384
|
+
async execute(id, args, ctx = {}, options) {
|
|
385
|
+
const attached = await this.attach(ctx, options);
|
|
386
|
+
return attached.nativeRegistry.execute(id, args, attached);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async #joinAgent(agent) {
|
|
390
|
+
if (!agent || !agentBelongsToScope(agent, this.agentScope)) return;
|
|
391
|
+
const calls = [...this.#calls].filter(call => call.childId === agent.id);
|
|
392
|
+
for (const call of calls) call.controller.abort('Child closed');
|
|
393
|
+
if (agent.driverPromise) await agent.driverPromise;
|
|
394
|
+
else while (agent.__driverStarted) await pause();
|
|
395
|
+
await Promise.all(calls.map(call => call.promise.catch(() => {})));
|
|
396
|
+
consumeNotificationForAgent(agent.id);
|
|
397
|
+
this.#persistAgents();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async #joinShell(taskId) {
|
|
401
|
+
while (true) {
|
|
402
|
+
const result = await this.taskManager.waitForTask(this.sessionId, taskId, { timeoutMs: 1000 });
|
|
403
|
+
if (!result.ok) throw new Error(result.error);
|
|
404
|
+
if (!result.timedOut) return result.task;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Completion records remain facts, not user messages or automatic triggers. */
|
|
409
|
+
evidence({ limit = 50 } = {}) {
|
|
410
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error('Invalid evidence limit');
|
|
411
|
+
this.#persistAgents();
|
|
412
|
+
const recent = items => items.sort((a, b) => String(b.updatedAt ?? b.task?.updatedAt ?? '').localeCompare(String(a.updatedAt ?? a.task?.updatedAt ?? ''))).slice(0, limit);
|
|
413
|
+
return { namespace: this.scope.key, sessionId: this.sessionId,
|
|
414
|
+
tasks: recent(jsonFiles(this.#manager.store.sessionDir(this.sessionId))),
|
|
415
|
+
agents: clone([...this.#agents.values()].slice(-limit)), completions: recent(jsonFiles(join(this.dataRoot, 'completions'))),
|
|
416
|
+
toolResults: recent(jsonFiles(join(this.dataRoot, 'tool-results'))).map(result => ({ ...result,
|
|
417
|
+
output: result.output.slice(0, 8192), rawPath: join(this.dataRoot, 'tool-results', `${result.id}.json`) })) };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Explicit cancellation stops and joins this Person's effects; normal episode
|
|
421
|
+
* commit simply discards attach()'s context and never calls this method. */
|
|
422
|
+
cancel(input = 'Person cancelled') {
|
|
423
|
+
const { reason = 'Person cancelled', episodeId = null } = typeof input === 'object' ? input : { reason: input };
|
|
424
|
+
if (typeof input === 'object' && input.ownerId != null && input.ownerId !== this.scope.ownerId) throw denied();
|
|
425
|
+
if (episodeId) this.#cancelledEpisodes.add(episodeId);
|
|
426
|
+
else for (const id of this.#episodes) this.#cancelledEpisodes.add(id);
|
|
427
|
+
if (this.#stopping) return this.#stopping.then(() => this.cancel(input));
|
|
428
|
+
this.#stopAll = !episodeId;
|
|
429
|
+
this.#stopping = Promise.resolve().then(async () => {
|
|
430
|
+
const calls = [...this.#calls].filter(call => !episodeId || call.episodeId === episodeId);
|
|
431
|
+
for (const call of calls) call.controller.abort(reason);
|
|
432
|
+
const agents = [...getAgentRegistry().values()].filter(agent => agentBelongsToScope(agent, this.agentScope) && (!episodeId || this.#agentEpisodes.get(agent.id) === episodeId));
|
|
433
|
+
for (const agent of agents) {
|
|
434
|
+
if (agent.__driverStarted || !isTerminalAgentStatus(agent.status)) await closeAgent.execute({ agent_id: agent.id }, { sessionId: this.sessionId, parentEngineDeps: { parentSessionId: this.sessionId, parentVpId: this.parentVpId, parentThreadId: this.threadId }, taskManager: this.taskManager });
|
|
435
|
+
}
|
|
436
|
+
const shells = this.taskManager.listActiveTasks(this.sessionId).filter(task => task.kind === 'shell' && (!episodeId || task.source?.episodeId === episodeId));
|
|
437
|
+
for (const task of shells) {
|
|
438
|
+
const result = this.taskManager.cancelTask(this.sessionId, task.id);
|
|
439
|
+
if (!result.ok) throw new Error(result.error);
|
|
440
|
+
}
|
|
441
|
+
await Promise.all([...agents.map(agent => this.#joinAgent(agent)), ...shells.map(task => this.#joinShell(task.id)), ...calls.map(call => call.promise.catch(() => {}))]);
|
|
442
|
+
return this.evidence();
|
|
443
|
+
}).finally(() => { this.#stopping = null; this.#stopAll = false; });
|
|
444
|
+
return this.#stopping;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
close(reason = 'Person task host closed') {
|
|
448
|
+
if (this.#closePromise) return this.#closePromise;
|
|
449
|
+
this.#closed = true;
|
|
450
|
+
const pending = this.#stopping ?? Promise.resolve();
|
|
451
|
+
this.#closePromise = pending.then(() => this.cancel(reason)).then(result => {
|
|
452
|
+
if (liveHosts.get(this.scope.key) === this) liveHosts.delete(this.scope.key);
|
|
453
|
+
return result;
|
|
454
|
+
});
|
|
455
|
+
return this.#closePromise;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function scopedHost(options) {
|
|
460
|
+
const scope = identity(options);
|
|
461
|
+
return liveHosts.get(scope.key) ?? new ScopedPersonTaskHost(options);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Instance/runtime facade. Each admitted owner/Person gets an independently
|
|
465
|
+
* persistent scoped host; contexts have stable task identity across episodes.
|
|
466
|
+
* context({ episode, provider, selection, effortDecision, parentToolRegistry })
|
|
467
|
+
* returns a native ToolContext. snapshot(ownerId) is observational evidence.
|
|
468
|
+
* cancel({ ownerId, episodeId }) only stops that episode's launched effects;
|
|
469
|
+
* omit episodeId for an explicit owner stop. close() stops all this facade owns.
|
|
470
|
+
* A bound { ownerId, personId } constructor also supports attach()/execute().
|
|
471
|
+
*/
|
|
472
|
+
export class PersonTaskHost {
|
|
473
|
+
#options;
|
|
474
|
+
#bound = null;
|
|
475
|
+
#owners = new Map();
|
|
476
|
+
#closed = false;
|
|
477
|
+
#closePromise = null;
|
|
478
|
+
|
|
479
|
+
constructor(options = {}) {
|
|
480
|
+
const base = identity({ ...options, ownerId: options.ownerId ?? '_runtime', personId: options.personId ?? '_runtime' });
|
|
481
|
+
this.#options = { ...options, yeaftDir: base.yeaftDir, namespace: base.namespace };
|
|
482
|
+
if (options.ownerId != null) this.#bound = new ScopedPersonTaskHost(this.#options);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
#host(ownerId, personId) {
|
|
486
|
+
if (this.#closed) throw Object.assign(new Error('Person task host is closed'), { code: 'TASK_HOST_CLOSED' });
|
|
487
|
+
if (this.#bound) {
|
|
488
|
+
if (ownerId !== this.#bound.scope.ownerId || (personId != null && personId !== this.#bound.scope.personId)) throw denied();
|
|
489
|
+
return this.#bound;
|
|
490
|
+
}
|
|
491
|
+
const existing = this.#owners.get(ownerId);
|
|
492
|
+
if (existing) {
|
|
493
|
+
if (personId != null && existing.scope.personId !== personId) throw denied();
|
|
494
|
+
return existing;
|
|
495
|
+
}
|
|
496
|
+
const host = scopedHost({ ...this.#options, ownerId,
|
|
497
|
+
personId: personId ?? `person-${digest([this.#options.namespace, ownerId]).slice(0, 32)}` });
|
|
498
|
+
this.#owners.set(ownerId, host);
|
|
499
|
+
return host;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async context(input = {}) {
|
|
503
|
+
const episode = input.episode;
|
|
504
|
+
if (!episode?.id || !episode.ownerId || !episode.personId
|
|
505
|
+
|| (episode.namespace != null && episode.namespace !== this.#options.namespace)) throw denied();
|
|
506
|
+
return this.#host(episode.ownerId, episode.personId).context(input);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
snapshot(ownerId) { return this.#host(ownerId).snapshot(ownerId); }
|
|
510
|
+
|
|
511
|
+
cancel(input = {}) {
|
|
512
|
+
if (this.#bound) return this.#bound.cancel(input);
|
|
513
|
+
if (!input || typeof input.ownerId !== 'string' || !input.ownerId) throw denied();
|
|
514
|
+
const host = this.#owners.get(input.ownerId);
|
|
515
|
+
return host ? host.cancel(input) : Promise.resolve({ tasks: [], agents: [], completions: [] });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
close(reason = 'Person task host closed') {
|
|
519
|
+
if (this.#closePromise) return this.#closePromise;
|
|
520
|
+
this.#closed = true;
|
|
521
|
+
const hosts = this.#bound ? [this.#bound] : [...this.#owners.values()];
|
|
522
|
+
this.#closePromise = Promise.all(hosts.map(host => host.close(reason)));
|
|
523
|
+
return this.#closePromise;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Compatibility for direct owner/Person embedding. The runtime uses context.
|
|
527
|
+
attach(...args) { return this.#bound.attach(...args); }
|
|
528
|
+
execute(...args) { return this.#bound.execute(...args); }
|
|
529
|
+
evidence(...args) { return this.#bound.evidence(...args); }
|
|
530
|
+
allowedToolIds(...args) { return this.#bound.allowedToolIds(...args); }
|
|
531
|
+
get scope() { return this.#bound?.scope; }
|
|
532
|
+
get dataRoot() { return this.#bound?.dataRoot; }
|
|
533
|
+
get yeaftDir() { return this.#options.yeaftDir; }
|
|
534
|
+
get sessionId() { return this.#bound?.sessionId; }
|
|
535
|
+
get parentVpId() { return this.#bound?.parentVpId; }
|
|
536
|
+
get threadId() { return this.#bound?.threadId; }
|
|
537
|
+
get taskManager() { return this.#bound?.taskManager; }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
export function createPersonTaskHost(options = {}) {
|
|
541
|
+
return options.ownerId != null ? scopedHost(options) : new PersonTaskHost(options);
|
|
542
|
+
}
|
|
@@ -110,6 +110,7 @@ export function isRestrictedToolName(name) {
|
|
|
110
110
|
* yeaftDir?: string,
|
|
111
111
|
* parentName?: string,
|
|
112
112
|
* parentVpId?: string,
|
|
113
|
+
* childVpId?: string, // optional host ownership; Session defaults unchanged
|
|
113
114
|
* parentSessionId?: string|null,
|
|
114
115
|
* projectSessionIds?: string[],
|
|
115
116
|
* projectLabel?: string,
|
|
@@ -158,6 +159,7 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
158
159
|
managedCliReady: deps.managedCliReady || null,
|
|
159
160
|
toolStats: deps.toolStats || null,
|
|
160
161
|
taskManager: deps.taskManager || null,
|
|
162
|
+
vpId: deps.childVpId || null,
|
|
161
163
|
});
|
|
162
164
|
// Same-turn result plumbing: inherit the parent's coordinator so any
|
|
163
165
|
// result-producing child task launched from this sub-agent uses the shared
|
|
@@ -216,7 +218,9 @@ export function startSubAgent(agent, deps = {}) {
|
|
|
216
218
|
|
|
217
219
|
// Background driver — pumps queued user messages through engine.query
|
|
218
220
|
// turn by turn until the agent reaches a terminal state.
|
|
219
|
-
|
|
221
|
+
// Terminal status can precede resource cleanup. Hosts that own detached
|
|
222
|
+
// execution await this promise before reporting cancellation/shutdown joined.
|
|
223
|
+
agent.driverPromise = driveSubAgent(agent, subEngine, baseVpPersona, deps).catch((err) => {
|
|
220
224
|
// The driver normally handles its own failures (stream try/catch +
|
|
221
225
|
// terminal transition). This .catch covers genuinely unexpected
|
|
222
226
|
// throws between turns (e.g. inside dequeueNextUserPrompt) so we
|
|
@@ -478,6 +482,7 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
|
|
|
478
482
|
vpPersona,
|
|
479
483
|
sessionId: agent.parentSessionId || deps.parentSessionId || null,
|
|
480
484
|
threadId: agent.id,
|
|
485
|
+
senderVpId: deps.childVpId || undefined,
|
|
481
486
|
// SpawnAgent records the caller-provided cwd on the agent. Thread it
|
|
482
487
|
// into the child Engine just like a parent query's workDir so child
|
|
483
488
|
// file tools resolve relative paths in the requested workspace.
|