minimal-agent 0.18.7 → 0.18.9

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.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/src/context/persistContext.js +5 -1
  3. package/src/context/persistContext.ts +5 -2
  4. package/src/loop.js +75 -25
  5. package/src/loop.ts +72 -23
  6. package/src/memory/factLearning.js +3 -3
  7. package/src/memory/factLearning.ts +3 -7
  8. package/src/memory/fileStore.js +2 -34
  9. package/src/memory/fileStore.ts +2 -26
  10. package/src/memory/jobPolicy.js +21 -13
  11. package/src/memory/jobPolicy.ts +22 -11
  12. package/src/memory/learning.js +3 -3
  13. package/src/memory/learning.ts +3 -3
  14. package/src/memory/learningModel.js +4 -2
  15. package/src/memory/learningModel.ts +4 -2
  16. package/src/prompts/delegation.js +15 -0
  17. package/src/prompts/delegation.ts +20 -0
  18. package/src/prompts/policyRegistry.js +7 -10
  19. package/src/prompts/policyRegistry.ts +9 -10
  20. package/src/prompts/promptAssembly.js +16 -23
  21. package/src/prompts/promptAssembly.ts +22 -27
  22. package/src/prompts/visualPresentation.js +28 -0
  23. package/src/prompts/visualPresentation.ts +26 -0
  24. package/src/runtime/agentRuntimeContext.js +3 -0
  25. package/src/runtime/agentRuntimeContext.ts +4 -0
  26. package/src/runtime/fileLock.js +75 -0
  27. package/src/runtime/fileLock.ts +73 -0
  28. package/src/runtime/finalAnswer.js +4 -2
  29. package/src/runtime/finalAnswer.ts +6 -2
  30. package/src/runtime/outputContract.js +17 -0
  31. package/src/runtime/outputContract.ts +34 -0
  32. package/src/session/runAgentTurn.js +7 -2
  33. package/src/session/runAgentTurn.ts +7 -2
  34. package/src/session/runtimeStore.js +11 -28
  35. package/src/session/runtimeStore.ts +9 -23
  36. package/src/tools/agent/agent.js +9 -6
  37. package/src/tools/agent/agent.ts +9 -5
  38. package/src/tools/memory/maintenance.js +2 -0
  39. package/src/tools/memory/maintenance.ts +2 -0
  40. package/src/tools/memory/memory.js +58 -14
  41. package/src/tools/memory/memory.ts +57 -13
  42. package/src/tools/memory/policy.js +16 -1
  43. package/src/tools/memory/policy.ts +16 -1
  44. package/src/tools/skill/searchSkills.js +2 -2
  45. package/src/tools/skill/searchSkills.ts +2 -2
  46. package/src/tools/skill/skill.js +4 -0
  47. package/src/tools/skill/skill.ts +3 -0
  48. package/src/tools/toolPromptGuides.js +4 -3
  49. package/src/tools/toolPromptGuides.ts +4 -3
  50. package/src/types.ts +4 -0
  51. package/src/web/protocol.ts +3 -1
@@ -1,5 +1,7 @@
1
1
  import type { Tool, ToolAvailability, ToolCallContext } from '../types.js';
2
2
  import type { ToolGate } from '../toolGate.js';
3
+ import { resolveOutputContract, type OutputContract } from './outputContract.js';
4
+ export { WEB_OUTPUT, resolveOutputContract, type OutputContract } from './outputContract.js';
3
5
  import { isMemoryToolName } from '../tools/capabilities.js';
4
6
  import type { ComputerAutomationIntent } from '../tools/computeruse/computerIntent.js';
5
7
 
@@ -26,6 +28,7 @@ export type MemoryAccessInput = MemoryAccess | 'local-owner';
26
28
  */
27
29
  export interface AgentRuntimeContext {
28
30
  readonly surface: AgentSurface;
31
+ readonly output?: OutputContract;
29
32
  /** Concrete frontend identity; surface remains the execution capability class. */
30
33
  readonly host?: 'desktop' | 'tui' | 'cli' | 'stream-json' | 'workflow';
31
34
  readonly profile: AgentProfile;
@@ -75,6 +78,7 @@ export function createAgentRuntimeContext(
75
78
  const { browser: rawBrowser, ...rest } = input;
76
79
  return Object.freeze({
77
80
  ...rest,
81
+ output: resolveOutputContract(input.surface, input.output),
78
82
  ...(rawBrowser
79
83
  ? {
80
84
  browser: normalizeBrowserRuntime(rawBrowser),
@@ -0,0 +1,75 @@
1
+ import { mkdir, stat } from 'node:fs/promises';
2
+ import * as fs from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import lockfile from 'proper-lockfile';
6
+ /** Shared heartbeat protocol for local file-backed stores, not a database lock.
7
+ * Never reclaim legacy regular-file locks: old hosts do not speak this protocol.
8
+ * proper-lockfile fails closed on compromised ownership (its default handler).
9
+ */
10
+ export async function acquireStateFileLock(path, options = {}) {
11
+ const { signal, waitMs = 15_000, label = 'STATE' } = options;
12
+ signal?.throwIfAborted();
13
+ const target = resolve(path);
14
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
15
+ const deadline = Date.now() + waitMs;
16
+ while (true) {
17
+ signal?.throwIfAborted();
18
+ const legacy = await stat(`${target}.lock`).catch((error) => {
19
+ if (error.code === 'ENOENT')
20
+ return null;
21
+ throw error;
22
+ });
23
+ if (legacy?.isFile()) {
24
+ throw new Error(`${label}_LEGACY_LOCK:请停止旧版本宿主并检查遗留锁:${target}.lock`);
25
+ }
26
+ try {
27
+ let owned = false;
28
+ let identity;
29
+ const guardedFs = {
30
+ ...fs,
31
+ rmdir(lockPath, callback) {
32
+ if (!owned)
33
+ return fs.rmdir(lockPath, callback);
34
+ // Also guard immediate release, before the next heartbeat could detect
35
+ // replacement. Never recursively remove a lock directory.
36
+ fs.stat(lockPath, (error, current) => {
37
+ if (error || !identity || current.dev !== identity.dev ||
38
+ current.ino !== identity.ino || current.birthtimeMs !== identity.birthtimeMs) {
39
+ callback(Object.assign(new Error(`锁所有权已丢失:${target}`), { code: 'ECOMPROMISED' }));
40
+ return;
41
+ }
42
+ fs.rmdir(lockPath, callback);
43
+ });
44
+ },
45
+ };
46
+ const release = await lockfile.lock(target, {
47
+ fs: guardedFs,
48
+ realpath: false,
49
+ retries: 0,
50
+ stale: 120_000,
51
+ update: 10_000,
52
+ });
53
+ owned = true;
54
+ try {
55
+ identity = await stat(`${target}.lock`);
56
+ }
57
+ catch (error) {
58
+ await release().catch(() => undefined);
59
+ throw error;
60
+ }
61
+ if (signal?.aborted) {
62
+ await release();
63
+ signal.throwIfAborted();
64
+ }
65
+ return release;
66
+ }
67
+ catch (error) {
68
+ if (error.code !== 'ELOCKED')
69
+ throw error;
70
+ if (Date.now() >= deadline)
71
+ throw new Error(`${label}_LOCK_TIMEOUT:等待文件锁超时:${target}`);
72
+ await delay(40, undefined, { signal });
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdir, stat } from 'node:fs/promises';
2
+ import * as fs from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import lockfile from 'proper-lockfile';
6
+
7
+ /** Shared heartbeat protocol for local file-backed stores, not a database lock.
8
+ * Never reclaim legacy regular-file locks: old hosts do not speak this protocol.
9
+ * proper-lockfile fails closed on compromised ownership (its default handler).
10
+ */
11
+ export async function acquireStateFileLock(
12
+ path: string,
13
+ options: { signal?: AbortSignal; waitMs?: number; label?: string } = {},
14
+ ): Promise<() => Promise<void>> {
15
+ const { signal, waitMs = 15_000, label = 'STATE' } = options;
16
+ signal?.throwIfAborted();
17
+ const target = resolve(path);
18
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
19
+ const deadline = Date.now() + waitMs;
20
+ while (true) {
21
+ signal?.throwIfAborted();
22
+ const legacy = await stat(`${target}.lock`).catch((error) => {
23
+ if (error.code === 'ENOENT') return null;
24
+ throw error;
25
+ });
26
+ if (legacy?.isFile()) {
27
+ throw new Error(`${label}_LEGACY_LOCK:请停止旧版本宿主并检查遗留锁:${target}.lock`);
28
+ }
29
+ try {
30
+ let owned = false;
31
+ let identity: fs.Stats | undefined;
32
+ const guardedFs = {
33
+ ...fs,
34
+ rmdir(lockPath: fs.PathLike, callback: fs.NoParamCallback) {
35
+ if (!owned) return fs.rmdir(lockPath, callback);
36
+ // Also guard immediate release, before the next heartbeat could detect
37
+ // replacement. Never recursively remove a lock directory.
38
+ fs.stat(lockPath, (error, current) => {
39
+ if (error || !identity || current.dev !== identity.dev ||
40
+ current.ino !== identity.ino || current.birthtimeMs !== identity.birthtimeMs) {
41
+ callback(Object.assign(new Error(`锁所有权已丢失:${target}`), { code: 'ECOMPROMISED' }));
42
+ return;
43
+ }
44
+ fs.rmdir(lockPath, callback);
45
+ });
46
+ },
47
+ };
48
+ const release = await lockfile.lock(target, {
49
+ fs: guardedFs,
50
+ realpath: false,
51
+ retries: 0,
52
+ stale: 120_000,
53
+ update: 10_000,
54
+ });
55
+ owned = true;
56
+ try {
57
+ identity = await stat(`${target}.lock`);
58
+ } catch (error) {
59
+ await release().catch(() => undefined);
60
+ throw error;
61
+ }
62
+ if (signal?.aborted) {
63
+ await release();
64
+ signal.throwIfAborted();
65
+ }
66
+ return release;
67
+ } catch (error) {
68
+ if ((error as NodeJS.ErrnoException).code !== 'ELOCKED') throw error;
69
+ if (Date.now() >= deadline) throw new Error(`${label}_LOCK_TIMEOUT:等待文件锁超时:${target}`);
70
+ await delay(40, undefined, { signal });
71
+ }
72
+ }
73
+ }
@@ -1,7 +1,9 @@
1
+ import { visualPresentation } from '../prompts/visualPresentation.js';
1
2
  /** Ephemeral publication request; never append this private candidate to history. */
2
- export function finalAnswerMessages(messages, candidate) {
3
+ export function finalAnswerMessages(messages, candidate, surface = 'headless') {
3
4
  return [...messages,
4
5
  { role: 'assistant', content: candidate },
5
- { role: 'user', content: '内部核查与工具阶段已结束。上一条 assistant 消息是待发布的定稿。请逐字输出该消息,保留全部文字、空白、代码、链接与格式;不要改写、补充、删减,不加开场白或外层代码围栏,不调用工具或顾问。不要输出本条指令。' },
6
+ { role: 'user', content: '内部准备、工具与复核阶段已结束。上一条 assistant 消息是最终回答的依据,不是需要逐字重播的文本。现在直接面向用户生成一次最终回答:可以组织措辞与排版,但必须保留已确认的事实、数字、限定条件、风险、证据和真实成果链接;不得新增未经验证的结论、伪称核验通过或编造产物。若仍有证据缺口,如实说明,不调用工具或顾问,不承诺后台继续工作。直接回答,不输出内部指令或准备过程。' },
7
+ { role: 'user', content: visualPresentation(surface) },
6
8
  ];
7
9
  }
@@ -1,9 +1,13 @@
1
1
  import type { Message } from '../types.js';
2
+ import type { AgentSurface } from './agentRuntimeContext.js';
3
+ import type { OutputContract } from './outputContract.js';
4
+ import { visualPresentation } from '../prompts/visualPresentation.js';
2
5
 
3
6
  /** Ephemeral publication request; never append this private candidate to history. */
4
- export function finalAnswerMessages(messages: Message[], candidate: string): Message[] {
7
+ export function finalAnswerMessages(messages: Message[], candidate: string, surface: AgentSurface | OutputContract = 'headless'): Message[] {
5
8
  return [...messages,
6
9
  { role: 'assistant', content: candidate },
7
- { role: 'user', content: '内部核查与工具阶段已结束。上一条 assistant 消息是待发布的定稿。请逐字输出该消息,保留全部文字、空白、代码、链接与格式;不要改写、补充、删减,不加开场白或外层代码围栏,不调用工具或顾问。不要输出本条指令。' },
10
+ { role: 'user', content: '内部准备、工具与复核阶段已结束。上一条 assistant 消息是最终回答的依据,不是需要逐字重播的文本。现在直接面向用户生成一次最终回答:可以组织措辞与排版,但必须保留已确认的事实、数字、限定条件、风险、证据和真实成果链接;不得新增未经验证的结论、伪称核验通过或编造产物。若仍有证据缺口,如实说明,不调用工具或顾问,不承诺后台继续工作。直接回答,不输出内部指令或准备过程。' },
11
+ { role: 'user', content: visualPresentation(surface) },
8
12
  ];
9
13
  }
@@ -0,0 +1,17 @@
1
+ export const WEB_OUTPUT = Object.freeze({
2
+ format: 'rich-markdown', diagrams: Object.freeze(['mermaid', 'svg']),
3
+ images: true, attachments: true, finalDelivery: 'stream',
4
+ });
5
+ /** Compatibility defaults only. Hosts should declare their actual renderer. */
6
+ export function resolveOutputContract(surface, output) {
7
+ const value = output ?? (surface === 'desktop' ? WEB_OUTPUT : {
8
+ format: surface === 'tui' || surface === 'cli' ? 'terminal' : 'unknown',
9
+ diagrams: [], images: false, attachments: false,
10
+ finalDelivery: surface === 'tui' ? 'stream' : 'atomic',
11
+ });
12
+ return Object.freeze({ ...value, diagrams: Object.freeze(value.format === 'rich-markdown' ? [...new Set(value.diagrams)] : []) });
13
+ }
14
+ /** Publication changes tool access, not the renderer or local execution rights. */
15
+ export function toolsForResponsePhase(tools, phase) {
16
+ return phase === 'publication' ? [] : [...tools];
17
+ }
@@ -0,0 +1,34 @@
1
+ import type { AgentSurface } from './agentRuntimeContext.js';
2
+
3
+ /** Trusted host presentation declaration, never an execution permission. */
4
+ export interface OutputContract {
5
+ readonly format: 'rich-markdown' | 'terminal' | 'structured' | 'unknown';
6
+ readonly diagrams: readonly ('mermaid' | 'svg')[];
7
+ readonly images: boolean;
8
+ readonly attachments: boolean;
9
+ readonly finalDelivery: 'stream' | 'atomic';
10
+ }
11
+
12
+ export const WEB_OUTPUT: OutputContract = Object.freeze({
13
+ format: 'rich-markdown', diagrams: Object.freeze(['mermaid', 'svg'] as const),
14
+ images: true, attachments: true, finalDelivery: 'stream',
15
+ });
16
+
17
+ /** Compatibility defaults only. Hosts should declare their actual renderer. */
18
+ export function resolveOutputContract(surface: AgentSurface, output?: OutputContract): OutputContract {
19
+ const value: OutputContract = output ?? (surface === 'desktop' ? WEB_OUTPUT : {
20
+ format: surface === 'tui' || surface === 'cli' ? 'terminal' : 'unknown',
21
+ diagrams: [], images: false, attachments: false,
22
+ finalDelivery: surface === 'tui' ? 'stream' : 'atomic',
23
+ });
24
+ return Object.freeze({ ...value, diagrams: Object.freeze(
25
+ value.format === 'rich-markdown' ? [...new Set(value.diagrams)] : [],
26
+ ) });
27
+ }
28
+
29
+ export type ResponsePhase = 'preparation' | 'publication';
30
+
31
+ /** Publication changes tool access, not the renderer or local execution rights. */
32
+ export function toolsForResponsePhase<T>(tools: readonly T[], phase: ResponsePhase): T[] {
33
+ return phase === 'publication' ? [] : [...tools];
34
+ }
@@ -5,7 +5,8 @@ import { listSessionSummaries, updateSessionMetadata, } from '../context/session
5
5
  import { workspaceIndexService } from '../context/workspaceIndex.js';
6
6
  import { createPromptCacheContext } from '../llm/promptCache.js';
7
7
  import { modelRefFromLegacy } from '../modelRef.js';
8
- import { buildBaseSystemPrompt, browserPromptRuntime, buildTransientPromptBundle, } from '../prompts/system.js';
8
+ import { browserPromptRuntime, buildTransientPromptBundle, } from '../prompts/system.js';
9
+ import { assembleStablePrompt } from '../prompts/promptAssembly.js';
9
10
  import { createAgentRuntimeContext, filterToolsForRuntime, memoryContextFromRuntime, } from '../runtime/agentRuntimeContext.js';
10
11
  import { filterToolsForCategories } from '../team/toolPolicy.js';
11
12
  import { ALL_TOOLS } from '../tools/index.js';
@@ -119,6 +120,7 @@ deps = {}) {
119
120
  // 稳定层进 history[0](runTurn 每轮重写它,必须逐字节可复现,见 runTurn.ts 的缓存不变量);
120
121
  // 易变层每轮单独计算,仅附加到当前用户消息的模型副本。
121
122
  const runtimeContext = createAgentRuntimeContext({
123
+ output: opts.output,
122
124
  surface: 'headless',
123
125
  profile: 'main',
124
126
  cwd: effectiveCwd,
@@ -146,11 +148,14 @@ deps = {}) {
146
148
  const turnTools = provider.source === 'gateway'
147
149
  ? filterToolsForCategories(runtimeTools, effectiveGatewayCategories)
148
150
  : runtimeTools;
149
- const systemPrompt = await buildBaseSystemPrompt(effectiveCwd, turnTools);
151
+ const systemPrompt = await assembleStablePrompt({ cwd: effectiveCwd, tools: turnTools, runtime: {
152
+ surface: runtimeContext.surface, output: runtimeContext.output,
153
+ } });
150
154
  const transientBundle = await buildTransientPromptBundle({
151
155
  cwd: effectiveCwd,
152
156
  tools: turnTools,
153
157
  runtime: {
158
+ output: runtimeContext.output,
154
159
  surface: 'headless',
155
160
  cwd: effectiveCwd,
156
161
  workspaceId: workspace?.id,
@@ -9,10 +9,10 @@ import { workspaceIndexService } from '../context/workspaceIndex.js';
9
9
  import { createPromptCacheContext } from '../llm/promptCache.js';
10
10
  import { type ModelRef, modelRefFromLegacy } from '../modelRef.js';
11
11
  import {
12
- buildBaseSystemPrompt,
13
12
  browserPromptRuntime,
14
13
  buildTransientPromptBundle,
15
14
  } from '../prompts/system.js';
15
+ import { assembleStablePrompt } from '../prompts/promptAssembly.js';
16
16
  import {
17
17
  createAgentRuntimeContext,
18
18
  filterToolsForRuntime,
@@ -31,6 +31,7 @@ import {
31
31
  } from './sessionState.js';
32
32
 
33
33
  export interface RunAgentTurnOptions {
34
+ output?: import('../runtime/outputContract.js').OutputContract;
34
35
  /** Host execution identity: distinct triggers must not reuse completed plugin work. */
35
36
  executionId?: string;
36
37
  input: string;
@@ -178,6 +179,7 @@ export async function runAgentTurn(
178
179
  // 稳定层进 history[0](runTurn 每轮重写它,必须逐字节可复现,见 runTurn.ts 的缓存不变量);
179
180
  // 易变层每轮单独计算,仅附加到当前用户消息的模型副本。
180
181
  const runtimeContext = createAgentRuntimeContext({
182
+ output: opts.output,
181
183
  surface: 'headless',
182
184
  profile: 'main',
183
185
  cwd: effectiveCwd,
@@ -216,11 +218,14 @@ export async function runAgentTurn(
216
218
  provider.source === 'gateway'
217
219
  ? filterToolsForCategories(runtimeTools, effectiveGatewayCategories)
218
220
  : runtimeTools;
219
- const systemPrompt = await buildBaseSystemPrompt(effectiveCwd, turnTools);
221
+ const systemPrompt = await assembleStablePrompt({ cwd: effectiveCwd, tools: turnTools, runtime: {
222
+ surface: runtimeContext.surface, output: runtimeContext.output,
223
+ } });
220
224
  const transientBundle = await buildTransientPromptBundle({
221
225
  cwd: effectiveCwd,
222
226
  tools: turnTools,
223
227
  runtime: {
228
+ output: runtimeContext.output,
224
229
  surface: 'headless',
225
230
  cwd: effectiveCwd,
226
231
  workspaceId: workspace?.id,
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { mkdir, open, readFile, readdir, rename, rm, stat, } from 'node:fs/promises';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { agentStateDir } from '../context/agentHome.js';
5
+ import { acquireStateFileLock } from '../runtime/fileLock.js';
5
6
  function segment(value, fallback) {
6
7
  return ((value ?? fallback).replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) ||
7
8
  fallback);
@@ -20,38 +21,20 @@ export async function withRuntimeFileLock(path, work) {
20
21
  const tail = previous.then(() => current);
21
22
  queues.set(key, tail);
22
23
  await previous.catch(() => undefined);
23
- let lockHandle;
24
- const lockPath = `${path}.lock`;
24
+ let unlock;
25
25
  try {
26
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
27
- const deadline = Date.now() + 10_000;
28
- while (!lockHandle) {
29
- try {
30
- lockHandle = await open(lockPath, 'wx', 0o600);
31
- await lockHandle.writeFile(`${process.pid}\n${Date.now()}\n`);
32
- }
33
- catch (error) {
34
- if (error.code !== 'EEXIST')
35
- throw error;
36
- const age = Date.now() -
37
- (await stat(lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
38
- if (age > 30_000)
39
- await rm(lockPath, { force: true });
40
- else if (Date.now() >= deadline)
41
- throw new Error(`runtime lock timeout: ${path}`);
42
- else
43
- await new Promise((r) => setTimeout(r, 25));
44
- }
45
- }
26
+ unlock = await acquireStateFileLock(key, { label: 'RUNTIME' });
46
27
  return await work();
47
28
  }
48
29
  finally {
49
- await lockHandle?.close().catch(() => undefined);
50
- if (lockHandle)
51
- await rm(lockPath, { force: true }).catch(() => undefined);
52
- release();
53
- if (queues.get(key) === tail)
54
- queues.delete(key);
30
+ try {
31
+ await unlock?.();
32
+ }
33
+ finally {
34
+ release();
35
+ if (queues.get(key) === tail)
36
+ queues.delete(key);
37
+ }
55
38
  }
56
39
  }
57
40
  export async function readRuntimeJson(path, fallback) {
@@ -11,6 +11,7 @@ import {
11
11
  import { dirname, join, resolve } from 'node:path';
12
12
 
13
13
  import { agentStateDir } from '../context/agentHome.js';
14
+ import { acquireStateFileLock } from '../runtime/fileLock.js';
14
15
 
15
16
  export interface RuntimeIdentity {
16
17
  workspaceId?: string;
@@ -47,32 +48,17 @@ export async function withRuntimeFileLock<T>(
47
48
  const tail = previous.then(() => current);
48
49
  queues.set(key, tail);
49
50
  await previous.catch(() => undefined);
50
- let lockHandle: Awaited<ReturnType<typeof open>> | undefined;
51
- const lockPath = `${path}.lock`;
51
+ let unlock: (() => Promise<void>) | undefined;
52
52
  try {
53
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
- const deadline = Date.now() + 10_000;
55
- while (!lockHandle) {
56
- try {
57
- lockHandle = await open(lockPath, 'wx', 0o600);
58
- await lockHandle.writeFile(`${process.pid}\n${Date.now()}\n`);
59
- } catch (error) {
60
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
61
- const age =
62
- Date.now() -
63
- (await stat(lockPath).catch(() => ({ mtimeMs: Date.now() }))).mtimeMs;
64
- if (age > 30_000) await rm(lockPath, { force: true });
65
- else if (Date.now() >= deadline)
66
- throw new Error(`runtime lock timeout: ${path}`);
67
- else await new Promise((r) => setTimeout(r, 25));
68
- }
69
- }
53
+ unlock = await acquireStateFileLock(key, { label: 'RUNTIME' });
70
54
  return await work();
71
55
  } finally {
72
- await lockHandle?.close().catch(() => undefined);
73
- if (lockHandle) await rm(lockPath, { force: true }).catch(() => undefined);
74
- release();
75
- if (queues.get(key) === tail) queues.delete(key);
56
+ try {
57
+ await unlock?.();
58
+ } finally {
59
+ release();
60
+ if (queues.get(key) === tail) queues.delete(key);
61
+ }
76
62
  }
77
63
  }
78
64
 
@@ -25,6 +25,7 @@
25
25
  * ============================================================
26
26
  */
27
27
  import { randomUUID } from 'node:crypto';
28
+ import { AGENT_TOOL_DESCRIPTION } from '../../prompts/delegation.js';
28
29
  import { researchQuestionSchema, researchPreview } from './research.js';
29
30
  import { EventEmitter } from 'node:events';
30
31
  import { access } from 'node:fs/promises';
@@ -106,7 +107,7 @@ const inputSchema = z.object({
106
107
  const parameters = toToolParameters(inputSchema);
107
108
  // ---------------- 2. Description(给 LLM 看的工具说明) ----------------
108
109
  // 借鉴官方 AgentTool prompt 文案(任务书自包含 / 同轮并行 / 何时不用)。
109
- const description = 'Launch an isolated sub-agent with a self-contained prompt. Returns agent_id; run_in_background returns immediately, resume_agent_id resumes private history. explore is read-only; general uses write_scopes or an explicit worktree. context.researchQuestions defines question IDs, scope and stopping conditions; structured findings link conclusions to tool evidence. Full handoff and paged findings remain available through TaskOutput.';
110
+ const description = AGENT_TOOL_DESCRIPTION;
110
111
  // ---------------- 3. 子 agent 系统提示词(memoize,提示词缓存友好) ----------------
111
112
  /** 任务回传契约。能力与权限已经由 assembleBasePrompt profile 精确注入。 */
112
113
  const SUBAGENT_COMMON_APPENDIX = `# 你是子 agent(fork 分身)
@@ -128,10 +129,10 @@ const SUBAGENT_COMMON_APPENDIX = `# 你是子 agent(fork 分身)
128
129
  */
129
130
  const PROMPT_CACHE_TTL_MS = 60_000;
130
131
  const promptCache = new Map();
131
- async function buildSubSystemPrompt(cwd, def, allTools, conversationMode = 'private') {
132
+ async function buildSubSystemPrompt(cwd, def, allTools, conversationMode = 'private', output) {
132
133
  // Effective capabilities are part of prompt identity; a child must never
133
134
  // reuse a cached parent-capability prompt (notably Desktop WebBrowser).
134
- const key = `${cwd}::${def.type}::${conversationMode}::${allTools.map((tool) => tool.name).join(',')}`;
135
+ const key = `${cwd}::${def.type}::${conversationMode}::${JSON.stringify(output ?? null)}::${allTools.map((tool) => tool.name).join(',')}`;
135
136
  const hit = promptCache.get(key);
136
137
  if (hit && Date.now() - hit.at < PROMPT_CACHE_TTL_MS)
137
138
  return hit.value;
@@ -143,7 +144,7 @@ async function buildSubSystemPrompt(cwd, def, allTools, conversationMode = 'priv
143
144
  profile: def.type === 'general' ? 'subagent-general' : 'subagent-explore',
144
145
  includeProjectInstructions: true, // 子 agent 在同一仓库干活,项目约定必须继承
145
146
  includePlugins: false, // 插件是宿主命令面,子 agent 不该被引去激活插件
146
- runtime: { conversationMode },
147
+ runtime: { conversationMode, output },
147
148
  });
148
149
  const value = [base, SUBAGENT_COMMON_APPENDIX, def.promptAppendix].join('\n\n');
149
150
  promptCache.set(key, { value, at: Date.now() });
@@ -298,6 +299,7 @@ async function runAgentTaskImpl(input, signal, ctx, deps = {}) {
298
299
  ? 'denied'
299
300
  : 'read-only';
300
301
  const childRuntime = createAgentRuntimeContext({
302
+ output: ctx.output,
301
303
  surface: 'subagent',
302
304
  profile: def.type === 'general' ? 'subagent-general' : 'subagent-explore',
303
305
  conversationMode: ctx.conversationMode,
@@ -318,6 +320,7 @@ async function runAgentTaskImpl(input, signal, ctx, deps = {}) {
318
320
  includeProjectInstructions: true,
319
321
  includePlugins: false,
320
322
  runtime: {
323
+ output: childRuntime.output,
321
324
  surface: 'subagent',
322
325
  conversationMode: ctx.conversationMode,
323
326
  cwd,
@@ -341,10 +344,10 @@ async function runAgentTaskImpl(input, signal, ctx, deps = {}) {
341
344
  };
342
345
  // ---- 子 history:一条 system(压缩按 role 定位,必须存在)+ runQuery 自己 push 任务书 ----
343
346
  const history = deps.initialHistory?.length ? [...deps.initialHistory] : [
344
- { role: 'system', content: await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode) },
347
+ { role: 'system', content: await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode, childRuntime.output) },
345
348
  ];
346
349
  if (deps.initialHistory?.length) {
347
- const system = await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode);
350
+ const system = await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode, childRuntime.output);
348
351
  const index = history.findIndex((message) => message.role === 'system');
349
352
  if (index >= 0)
350
353
  history[index] = { role: 'system', content: system };
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { randomUUID } from 'node:crypto';
29
+ import { AGENT_TOOL_DESCRIPTION } from '../../prompts/delegation.js';
29
30
  import { researchQuestionSchema, researchPreview } from './research.js';
30
31
  import { EventEmitter } from 'node:events';
31
32
  import { access } from 'node:fs/promises';
@@ -152,7 +153,7 @@ const parameters = toToolParameters(inputSchema);
152
153
  // ---------------- 2. Description(给 LLM 看的工具说明) ----------------
153
154
  // 借鉴官方 AgentTool prompt 文案(任务书自包含 / 同轮并行 / 何时不用)。
154
155
 
155
- const description = 'Launch an isolated sub-agent with a self-contained prompt. Returns agent_id; run_in_background returns immediately, resume_agent_id resumes private history. explore is read-only; general uses write_scopes or an explicit worktree. context.researchQuestions defines question IDs, scope and stopping conditions; structured findings link conclusions to tool evidence. Full handoff and paged findings remain available through TaskOutput.';
156
+ const description = AGENT_TOOL_DESCRIPTION;
156
157
 
157
158
  // ---------------- 3. 子 agent 系统提示词(memoize,提示词缓存友好) ----------------
158
159
 
@@ -183,10 +184,11 @@ async function buildSubSystemPrompt(
183
184
  def: AgentTypeDef,
184
185
  allTools: Tool[],
185
186
  conversationMode: ToolCallContext['conversationMode'] = 'private',
187
+ output?: ToolCallContext['output'],
186
188
  ): Promise<string> {
187
189
  // Effective capabilities are part of prompt identity; a child must never
188
190
  // reuse a cached parent-capability prompt (notably Desktop WebBrowser).
189
- const key = `${cwd}::${def.type}::${conversationMode}::${allTools.map((tool) => tool.name).join(',')}`;
191
+ const key = `${cwd}::${def.type}::${conversationMode}::${JSON.stringify(output ?? null)}::${allTools.map((tool) => tool.name).join(',')}`;
190
192
  const hit = promptCache.get(key);
191
193
  if (hit && Date.now() - hit.at < PROMPT_CACHE_TTL_MS) return hit.value;
192
194
 
@@ -198,7 +200,7 @@ async function buildSubSystemPrompt(
198
200
  profile: def.type === 'general' ? 'subagent-general' : 'subagent-explore',
199
201
  includeProjectInstructions: true, // 子 agent 在同一仓库干活,项目约定必须继承
200
202
  includePlugins: false, // 插件是宿主命令面,子 agent 不该被引去激活插件
201
- runtime: { conversationMode },
203
+ runtime: { conversationMode, output },
202
204
  });
203
205
  const value = [base, SUBAGENT_COMMON_APPENDIX, def.promptAppendix].join('\n\n');
204
206
  promptCache.set(key, { value, at: Date.now() });
@@ -374,6 +376,7 @@ async function runAgentTaskImpl(
374
376
  ? 'denied'
375
377
  : 'read-only';
376
378
  const childRuntime = createAgentRuntimeContext({
379
+ output: ctx.output,
377
380
  surface: 'subagent',
378
381
  profile: def.type === 'general' ? 'subagent-general' : 'subagent-explore',
379
382
  conversationMode: ctx.conversationMode,
@@ -397,6 +400,7 @@ async function runAgentTaskImpl(
397
400
  includeProjectInstructions: true,
398
401
  includePlugins: false,
399
402
  runtime: {
403
+ output: childRuntime.output,
400
404
  surface: 'subagent',
401
405
  conversationMode: ctx.conversationMode,
402
406
  cwd,
@@ -421,10 +425,10 @@ async function runAgentTaskImpl(
421
425
 
422
426
  // ---- 子 history:一条 system(压缩按 role 定位,必须存在)+ runQuery 自己 push 任务书 ----
423
427
  const history: RunQueryOptions['history'] = deps.initialHistory?.length ? [...deps.initialHistory] : [
424
- { role: 'system', content: await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode) },
428
+ { role: 'system', content: await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode, childRuntime.output) },
425
429
  ];
426
430
  if (deps.initialHistory?.length) {
427
- const system = await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode);
431
+ const system = await buildSubSystemPrompt(cwd, def, subTools, ctx.conversationMode, childRuntime.output);
428
432
  const index = history.findIndex((message) => message.role === 'system');
429
433
  if (index >= 0) history[index] = { role: 'system', content: system };
430
434
  else history.unshift({ role: 'system', content: system });
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { backgroundLearningHealth } from '../../memory/jobPolicy.js';
2
3
  import { getWorkingDir } from '../../bootstrap/workingDir.js';
3
4
  import { sha256 } from '../../memory/codec.js';
4
5
  import { captureDependencies } from '../../memory/dependencies.js';
@@ -289,6 +290,7 @@ export const memoryMaintenanceTool = {
289
290
  ok: true,
290
291
  content: JSON.stringify({
291
292
  ...(await persistedLearningStatus(ctx.workspaceId)),
293
+ recentBackgroundFailures: backgroundLearningHealth(ctx.workspaceId),
292
294
  revision: store.revision,
293
295
  taskSummaries: {
294
296
  count: store.taskSummaries?.length ?? 0,
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { backgroundLearningHealth } from '../../memory/jobPolicy.js';
2
3
  import { getWorkingDir } from '../../bootstrap/workingDir.js';
3
4
  import { sha256 } from '../../memory/codec.js';
4
5
  import { captureDependencies } from '../../memory/dependencies.js';
@@ -388,6 +389,7 @@ export const memoryMaintenanceTool: Tool<z.infer<typeof schema>> = {
388
389
  content: JSON.stringify(
389
390
  {
390
391
  ...(await persistedLearningStatus(ctx.workspaceId)),
392
+ recentBackgroundFailures: backgroundLearningHealth(ctx.workspaceId),
391
393
  revision: store.revision,
392
394
  taskSummaries: {
393
395
  count: store.taskSummaries?.length ?? 0,