@fabric-harness/sdk 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/dist/action.d.ts +69 -0
  2. package/dist/action.d.ts.map +1 -0
  3. package/dist/action.js +119 -0
  4. package/dist/action.js.map +1 -0
  5. package/dist/agent-definition.d.ts +6 -0
  6. package/dist/agent-definition.d.ts.map +1 -1
  7. package/dist/agent-definition.js.map +1 -1
  8. package/dist/agent.d.ts +2 -0
  9. package/dist/agent.d.ts.map +1 -1
  10. package/dist/agent.js +35 -1
  11. package/dist/agent.js.map +1 -1
  12. package/dist/attachment-store-contract.d.ts +34 -0
  13. package/dist/attachment-store-contract.d.ts.map +1 -0
  14. package/dist/attachment-store-contract.js +0 -0
  15. package/dist/attachment-store-contract.js.map +1 -0
  16. package/dist/attachment-store.d.ts +107 -0
  17. package/dist/attachment-store.d.ts.map +1 -0
  18. package/dist/attachment-store.js +164 -0
  19. package/dist/attachment-store.js.map +1 -0
  20. package/dist/channel.d.ts +84 -0
  21. package/dist/channel.d.ts.map +1 -0
  22. package/dist/channel.js +115 -0
  23. package/dist/channel.js.map +1 -0
  24. package/dist/context-budget.d.ts +16 -0
  25. package/dist/context-budget.d.ts.map +1 -1
  26. package/dist/context-budget.js +38 -3
  27. package/dist/context-budget.js.map +1 -1
  28. package/dist/conversation-projection.d.ts +52 -0
  29. package/dist/conversation-projection.d.ts.map +1 -0
  30. package/dist/conversation-projection.js +207 -0
  31. package/dist/conversation-projection.js.map +1 -0
  32. package/dist/conversation-stream-contract.d.ts +37 -0
  33. package/dist/conversation-stream-contract.d.ts.map +1 -0
  34. package/dist/conversation-stream-contract.js +453 -0
  35. package/dist/conversation-stream-contract.js.map +1 -0
  36. package/dist/conversation-stream.d.ts +141 -0
  37. package/dist/conversation-stream.d.ts.map +1 -0
  38. package/dist/conversation-stream.js +159 -0
  39. package/dist/conversation-stream.js.map +1 -0
  40. package/dist/cost-budget.d.ts +106 -1
  41. package/dist/cost-budget.d.ts.map +1 -1
  42. package/dist/cost-budget.js +48 -2
  43. package/dist/cost-budget.js.map +1 -1
  44. package/dist/delivered-message.d.ts +90 -0
  45. package/dist/delivered-message.d.ts.map +1 -0
  46. package/dist/delivered-message.js +154 -0
  47. package/dist/delivered-message.js.map +1 -0
  48. package/dist/dispatch.d.ts +108 -0
  49. package/dist/dispatch.d.ts.map +1 -0
  50. package/dist/dispatch.js +179 -0
  51. package/dist/dispatch.js.map +1 -0
  52. package/dist/error.d.ts +1 -1
  53. package/dist/error.d.ts.map +1 -1
  54. package/dist/error.js.map +1 -1
  55. package/dist/history.d.ts +49 -2
  56. package/dist/history.d.ts.map +1 -1
  57. package/dist/history.js +163 -0
  58. package/dist/history.js.map +1 -1
  59. package/dist/in-memory-submission-store.d.ts +53 -0
  60. package/dist/in-memory-submission-store.d.ts.map +1 -0
  61. package/dist/in-memory-submission-store.js +340 -0
  62. package/dist/in-memory-submission-store.js.map +1 -0
  63. package/dist/index.d.ts +51 -13
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +28 -7
  66. package/dist/index.js.map +1 -1
  67. package/dist/loop.d.ts +29 -3
  68. package/dist/loop.d.ts.map +1 -1
  69. package/dist/loop.js +167 -24
  70. package/dist/loop.js.map +1 -1
  71. package/dist/model.d.ts +37 -0
  72. package/dist/model.d.ts.map +1 -1
  73. package/dist/model.js +250 -79
  74. package/dist/model.js.map +1 -1
  75. package/dist/otel-observer.d.ts +44 -0
  76. package/dist/otel-observer.d.ts.map +1 -0
  77. package/dist/otel-observer.js +165 -0
  78. package/dist/otel-observer.js.map +1 -0
  79. package/dist/persistence.d.ts +70 -0
  80. package/dist/persistence.d.ts.map +1 -0
  81. package/dist/persistence.js +17 -0
  82. package/dist/persistence.js.map +1 -0
  83. package/dist/persistent-agent.d.ts +86 -0
  84. package/dist/persistent-agent.d.ts.map +1 -0
  85. package/dist/persistent-agent.js +75 -0
  86. package/dist/persistent-agent.js.map +1 -0
  87. package/dist/pi-loop-runtime.d.ts.map +1 -1
  88. package/dist/pi-loop-runtime.js +7 -1
  89. package/dist/pi-loop-runtime.js.map +1 -1
  90. package/dist/profile.d.ts +33 -0
  91. package/dist/profile.d.ts.map +1 -0
  92. package/dist/profile.js +10 -0
  93. package/dist/profile.js.map +1 -0
  94. package/dist/providers.d.ts +14 -0
  95. package/dist/providers.d.ts.map +1 -1
  96. package/dist/providers.js +18 -1
  97. package/dist/providers.js.map +1 -1
  98. package/dist/result.d.ts +12 -0
  99. package/dist/result.d.ts.map +1 -1
  100. package/dist/result.js +26 -0
  101. package/dist/result.js.map +1 -1
  102. package/dist/retrieval.d.ts +61 -0
  103. package/dist/retrieval.d.ts.map +1 -0
  104. package/dist/retrieval.js +26 -0
  105. package/dist/retrieval.js.map +1 -0
  106. package/dist/runtime-mode.d.ts +1 -1
  107. package/dist/runtime-mode.d.ts.map +1 -1
  108. package/dist/runtime-mode.js +2 -1
  109. package/dist/runtime-mode.js.map +1 -1
  110. package/dist/scope-key.d.ts +25 -0
  111. package/dist/scope-key.d.ts.map +1 -0
  112. package/dist/scope-key.js +34 -0
  113. package/dist/scope-key.js.map +1 -0
  114. package/dist/session.d.ts +47 -1
  115. package/dist/session.d.ts.map +1 -1
  116. package/dist/session.js +364 -35
  117. package/dist/session.js.map +1 -1
  118. package/dist/skills.d.ts +33 -0
  119. package/dist/skills.d.ts.map +1 -0
  120. package/dist/skills.js +65 -0
  121. package/dist/skills.js.map +1 -0
  122. package/dist/store.d.ts +36 -1
  123. package/dist/store.d.ts.map +1 -1
  124. package/dist/store.js +83 -0
  125. package/dist/store.js.map +1 -1
  126. package/dist/stream-chunks.d.ts +42 -0
  127. package/dist/stream-chunks.d.ts.map +1 -0
  128. package/dist/stream-chunks.js +138 -0
  129. package/dist/stream-chunks.js.map +1 -0
  130. package/dist/strict.d.ts +40 -11
  131. package/dist/strict.d.ts.map +1 -1
  132. package/dist/strict.js +22 -7
  133. package/dist/strict.js.map +1 -1
  134. package/dist/submission-context.d.ts +26 -0
  135. package/dist/submission-context.d.ts.map +1 -0
  136. package/dist/submission-context.js +20 -0
  137. package/dist/submission-context.js.map +1 -0
  138. package/dist/submission-runner.d.ts +150 -0
  139. package/dist/submission-runner.d.ts.map +1 -0
  140. package/dist/submission-runner.js +767 -0
  141. package/dist/submission-runner.js.map +1 -0
  142. package/dist/submission-state.d.ts +50 -0
  143. package/dist/submission-state.d.ts.map +1 -0
  144. package/dist/submission-state.js +82 -0
  145. package/dist/submission-state.js.map +1 -0
  146. package/dist/submission-store-contract.d.ts +31 -0
  147. package/dist/submission-store-contract.d.ts.map +1 -0
  148. package/dist/submission-store-contract.js +730 -0
  149. package/dist/submission-store-contract.js.map +1 -0
  150. package/dist/submission-store.d.ts +365 -0
  151. package/dist/submission-store.d.ts.map +1 -0
  152. package/dist/submission-store.js +191 -0
  153. package/dist/submission-store.js.map +1 -0
  154. package/dist/submission-telemetry.d.ts +56 -0
  155. package/dist/submission-telemetry.d.ts.map +1 -0
  156. package/dist/submission-telemetry.js +36 -0
  157. package/dist/submission-telemetry.js.map +1 -0
  158. package/dist/testing-contracts.d.ts +17 -0
  159. package/dist/testing-contracts.d.ts.map +1 -0
  160. package/dist/testing-contracts.js +14 -0
  161. package/dist/testing-contracts.js.map +1 -0
  162. package/dist/tools.d.ts +32 -4
  163. package/dist/tools.d.ts.map +1 -1
  164. package/dist/tools.js +132 -6
  165. package/dist/tools.js.map +1 -1
  166. package/dist/types.d.ts +132 -3
  167. package/dist/types.d.ts.map +1 -1
  168. package/dist/types.js.map +1 -1
  169. package/package.json +16 -8
package/dist/session.js CHANGED
@@ -2,22 +2,75 @@ import { createHash } from 'node:crypto';
2
2
  import { CostBudgetTracker } from './cost-budget.js';
3
3
  import { inMemorySessionMemory } from './session-memory.js';
4
4
  import { FabricError } from './error.js';
5
- import { buildModelMessagesFromHistory } from './history.js';
5
+ import { SessionHistory, buildModelMessagesFromHistory, findTrailingDanglingToolCalls, findTrailingUnfinishedTasks } from './history.js';
6
6
  import { getLogger } from './logger.js';
7
7
  import { defaultLoopRuntime } from './loop.js';
8
8
  import { defaultModelProvider, generateWithRuntime } from './model.js';
9
9
  import { clampCommandTimeout, evaluateCommandPolicy, evaluateToolCallPolicy } from './policy.js';
10
10
  import { buildResultRetryPrompt, validateResult } from './result.js';
11
- import { evaluateContextBudget, isContextOverflowError } from './context-budget.js';
11
+ import { estimateSessionEntryTokens, evaluateContextBudget, isContextOverflowError } from './context-budget.js';
12
12
  import { policiedSandboxEnv } from './policied-sandbox.js';
13
13
  import { createSandboxEnv, createScopedSandboxEnv } from './sandbox.js';
14
14
  import { createFabricFs } from './filesystem.js';
15
15
  import { registerSandbox, serializeSandboxRef, unregisterSandbox } from './sandbox-ref.js';
16
16
  import path from 'node:path';
17
17
  import { defaultSessionStore } from './store.js';
18
- import { createBuiltinTools, createCommandTools, shellQuote } from './tools.js';
18
+ import { createActivateSkillTool, createBuiltinTools, createCommandTools, shellQuote } from './tools.js';
19
+ import { formatPackagedSkillResourceList, readPackagedSkillFile } from './skills.js';
19
20
  import { MAX_TASK_DEPTH } from './types.js';
20
21
  let nextTaskId = 0;
22
+ const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
23
+ const AFFINITY_KEY_REGEX = /^aff_[0-7][0-9A-HJKMNP-TV-Z]{25}$/;
24
+ function encodeBase32(buf, length) {
25
+ let result = '';
26
+ let bitIndex = 0; // 0 = MSB of buf[0]
27
+ for (let i = 0; i < length; i++) {
28
+ let value = 0;
29
+ for (let j = 0; j < 5; j++) {
30
+ const byteIndex = Math.floor(bitIndex / 8);
31
+ const bitInByte = 7 - (bitIndex % 8);
32
+ const byte = buf[byteIndex] ?? 0;
33
+ const bit = (byte >> bitInByte) & 1;
34
+ value = (value << 1) | bit;
35
+ bitIndex++;
36
+ }
37
+ result += CROCKFORD_ALPHABET[value];
38
+ }
39
+ return result;
40
+ }
41
+ /**
42
+ * Generate a deterministic `aff_\u003cULID\u003e` affinity key from an
43
+ * `(agentId, sessionId)` pair. The same pair always produces the same key,
44
+ * which is stable across restarts. Different pairs produce different keys
45
+ * with overwhelming probability.
46
+ */
47
+ export function generateAffinityKey(agentId, sessionId) {
48
+ const hash = createHash('sha256').update(agentId).update('\0').update(sessionId).digest();
49
+ // Build a 128-bit buffer from the hash, clearing the top 5 bits of the
50
+ // first byte so the first Crockford base32 char is in [0-7].
51
+ const combined = Buffer.alloc(16);
52
+ combined[0] = (hash[0] ?? 0) & 0x07;
53
+ combined.set(hash.slice(1, 16), 1);
54
+ return `aff_${encodeBase32(combined, 26)}`;
55
+ }
56
+ export function isValidAffinityKey(key) {
57
+ return AFFINITY_KEY_REGEX.test(key);
58
+ }
59
+ const COMPACTION_SUMMARIZATION_SYSTEM_PROMPT = 'Summarize durable agent session history for future context. Preserve user goals, decisions, tool/command effects, files touched, errors, policy/approval events, and pending work. Do not include secrets.';
60
+ const TURN_PREFIX_SUMMARIZATION_PROMPT = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
61
+
62
+ Summarize the prefix to provide context for the retained suffix:
63
+
64
+ ## Original Request
65
+ [What did the user ask for in this turn?]
66
+
67
+ ## Early Progress
68
+ - [Key decisions and work done in the prefix]
69
+
70
+ ## Context for Suffix
71
+ - [Information needed to understand the retained recent work]
72
+
73
+ Be concise. Focus on what's needed to understand the kept suffix.`;
21
74
  export class StubFabricSession {
22
75
  id;
23
76
  agent;
@@ -49,6 +102,7 @@ export class StubFabricSession {
49
102
  /** Set when this session has registered its sandbox in the in-process ref registry. */
50
103
  ownedSandboxRefId;
51
104
  costBudget;
105
+ skillCache = new Map();
52
106
  constructor(agent, id, options = {}) {
53
107
  this.agent = agent;
54
108
  this.id = id;
@@ -91,11 +145,33 @@ export class StubFabricSession {
91
145
  if (promptSubscriber)
92
146
  this.eventSubscribers.add(promptSubscriber);
93
147
  try {
148
+ await this.repairDanglingToolCalls();
94
149
  await this.emit('prompt_start', { text, ...(role ? { role: role.name } : {}) });
95
150
  const sandbox = createScopedSandboxEnv(await this.sandbox, options.cwd);
96
151
  const tools = this.resolveTools(sandbox, options.tools, options.commands);
97
- await this.recordEntry('user_prompt', { text, ...(role ? { role: role.name } : {}) });
98
- let messages = buildModelMessagesFromHistory(await this.history(), role);
152
+ // Submission-correlated prompts are idempotent by submission id: a
153
+ // recovered attempt whose input entry already landed (crash after the
154
+ // canonical write) resumes from history instead of double-applying.
155
+ let inputAlreadyApplied = false;
156
+ if (options.submission) {
157
+ const prior = await this.history();
158
+ const submissionId = options.submission.submissionId;
159
+ inputAlreadyApplied = (prior.entries ?? []).some((entry) => entry.type === 'user_prompt' && entry.data?.submissionId === submissionId);
160
+ }
161
+ if (!inputAlreadyApplied) {
162
+ await this.recordEntry('user_prompt', {
163
+ text,
164
+ ...(role ? { role: role.name } : {}),
165
+ ...(options.submission ? { submissionId: options.submission.submissionId } : {}),
166
+ });
167
+ }
168
+ // Input-applied boundary: the canonical input entry is durable and no
169
+ // provider work has started. The submission runner's input-applied
170
+ // marker CAS hangs off this callback; its failure aborts the turn
171
+ // (the attempt lost ownership).
172
+ await options.submission?.onInputApplied?.();
173
+ const sessionData = await this.history();
174
+ let messages = buildModelMessagesFromHistory(sessionData, role);
99
175
  if (!messages.some((message) => message.role === 'user' && message.content === text))
100
176
  messages.push({ role: 'user', content: text });
101
177
  messages = await this.autoCompactIfNeeded(messages, role, options);
@@ -105,14 +181,19 @@ export class StubFabricSession {
105
181
  await this.emit('prompt_end', { text, result: results });
106
182
  return validateResult(results, options.result, options.resultExtraction);
107
183
  }
184
+ // Use tool-based result extraction when a validator is provided and the caller
185
+ // has not explicitly opted into delimiter-based extraction.
186
+ const useResultTools = options.result !== undefined && options.resultExtraction === undefined;
108
187
  const runtime = options.loopRuntime ?? this.options.loopRuntime ?? defaultLoopRuntime;
109
188
  const runRuntime = () => runtime.runPrompt({
110
189
  sessionId: this.id,
190
+ ...(sessionData.affinityKey !== undefined ? { affinityKey: sessionData.affinityKey } : {}),
111
191
  text,
112
192
  messages,
113
193
  tools,
114
194
  sandbox,
115
195
  ...(typeof (options.model ?? this.options.model ?? this.agent.model) === 'string' ? { model: (options.model ?? this.options.model ?? this.agent.model) } : {}),
196
+ ...((options.thinkingLevel ?? this.options.thinkingLevel ?? this.agent.thinkingLevel) !== undefined ? { thinkingLevel: options.thinkingLevel ?? this.options.thinkingLevel ?? this.agent.thinkingLevel } : {}),
116
197
  ...((options.modelProvider ?? this.options.modelProvider) !== undefined ? { modelProvider: options.modelProvider ?? this.options.modelProvider } : {}),
117
198
  ...(options.maxIterations !== undefined ? { maxIterations: options.maxIterations } : {}),
118
199
  ...(options.modelTimeoutMs !== undefined ? { modelTimeoutMs: options.modelTimeoutMs } : {}),
@@ -125,7 +206,10 @@ export class StubFabricSession {
125
206
  costBudget: this.costBudget,
126
207
  requestCostLimitApproval: (ctx) => this.requestCostLimitApproval(ctx, options.approvalTimeoutMs ?? this.options.approvalTimeoutMs, options.onApproval),
127
208
  } : {}),
128
- executeToolCalls: (calls, policy, approvalTimeoutMs) => this.executeToolCalls(calls, tools, sandbox, policy, approvalTimeoutMs, options.onApproval),
209
+ ...(useResultTools ? { resultValidator: options.result } : {}),
210
+ agentId: this.agent.id,
211
+ ...(this.options.tenantId !== undefined ? { tenantId: this.options.tenantId } : {}),
212
+ executeToolCalls: (calls, policy, approvalTimeoutMs, attribution) => this.executeToolCalls(calls, tools, sandbox, policy, approvalTimeoutMs, options.onApproval, attribution),
129
213
  recordEntry: (type, data) => this.recordEntry(type, data),
130
214
  emit: (type, data) => this.emit(type, data),
131
215
  });
@@ -147,6 +231,10 @@ export class StubFabricSession {
147
231
  messages = buildModelMessagesFromHistory(await this.history(), role);
148
232
  result = await runRuntime();
149
233
  }
234
+ // If the loop produced a validated result via finish/give_up tools, return it directly.
235
+ if (result.validatedResult !== undefined) {
236
+ return result.validatedResult;
237
+ }
150
238
  const resultRetries = options.resultRetries ?? (options.result ? 1 : 0);
151
239
  for (let attempt = 0; attempt <= resultRetries; attempt += 1) {
152
240
  try {
@@ -218,7 +306,8 @@ export class StubFabricSession {
218
306
  if (!skill)
219
307
  throw new FabricError({ code: 'SKILL_NOT_FOUND', message: `Skill not found: ${name}`, details: { skill: name } });
220
308
  const argsText = options.args ? `\n\nArguments:\n${JSON.stringify(options.args, null, 2)}` : '';
221
- const result = await this.prompt(`${skill.content ?? ''}${argsText}`, options);
309
+ const packagedText = skill.packaged ? formatPackagedSkillResourceList(skill.packaged) : '';
310
+ const result = await this.prompt(`${skill.content ?? ''}${argsText}${packagedText}`, options);
222
311
  await this.recordEntry('skill_end', { skill: name });
223
312
  await this.emit('skill_end', { skill: name });
224
313
  return result;
@@ -380,10 +469,22 @@ export class StubFabricSession {
380
469
  const timeout = clampCommandTimeout(options.timeout, policy);
381
470
  if (timeout !== undefined)
382
471
  execOptions.timeout = timeout;
383
- await this.recordEntry('shell_command', { command });
384
472
  await this.emit('command_start', { command });
385
473
  const result = await sandbox.exec(command, execOptions);
386
- await this.recordEntry('shell_result', { command, exitCode: result.exitCode, stdout: result.stdout, stderr: result.stderr, ...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}) });
474
+ const redactedCommand = redactEnvValues(command, options.env);
475
+ const toolCallId = `shell-${Date.now()}`;
476
+ await this.recordEntry('user_prompt', { text: `Run shell command: ${redactedCommand}` });
477
+ await this.recordEntry('tool_call', { tool: 'bash', input: { command: redactedCommand }, toolCallId, synthetic: true });
478
+ await this.recordEntry('tool_result', {
479
+ tool: 'bash',
480
+ output: {
481
+ stdout: redactEnvValues(result.stdout, options.env),
482
+ stderr: redactEnvValues(result.stderr, options.env),
483
+ exitCode: result.exitCode,
484
+ },
485
+ toolCallId,
486
+ synthetic: true,
487
+ });
387
488
  await this.emit('command_end', { command, exitCode: result.exitCode, ...(result.durationMs !== undefined ? { durationMs: result.durationMs } : {}) });
388
489
  return result;
389
490
  }
@@ -439,6 +540,9 @@ export class StubFabricSession {
439
540
  async history() {
440
541
  return this.ensureData();
441
542
  }
543
+ async createSignalEntry(data) {
544
+ return this.recordEntry('signal', data);
545
+ }
442
546
  async sandboxRef(options) {
443
547
  const sandbox = await this.sandbox;
444
548
  const ref = registerSandbox(sandbox, { ownerSessionId: this.id });
@@ -521,13 +625,39 @@ export class StubFabricSession {
521
625
  async compact(options = {}) {
522
626
  const history = await this.history();
523
627
  const entries = history.entries ?? [];
524
- const keepRecentEntries = options.keepRecentEntries ?? 20;
525
- const compactedEntries = Math.max(0, entries.length - keepRecentEntries);
526
- const firstKeptEntry = compactedEntries > 0 ? entries[compactedEntries] : undefined;
527
- const compacted = compactedEntries > 0 ? entries.slice(0, compactedEntries) : [];
528
- const fileRefs = collectFileReferences(compacted);
529
- const baseSummary = options.summary ?? (options.generateSummary ? await this.generateCompactionSummary(compacted) : summarizeEntries(compacted));
628
+ const settings = this.options.compaction;
629
+ const explicitKeepRecentEntries = options.keepRecentEntries ?? settings?.keepRecentEntries;
630
+ const explicitKeepRecentTokens = options.keepRecentTokens ?? settings?.keepRecentTokens;
631
+ let compacted = [];
632
+ let firstKeptEntry;
633
+ let prep;
634
+ if (explicitKeepRecentEntries !== undefined) {
635
+ // Backward-compatible count-based cut.
636
+ const compactedCount = Math.max(0, entries.length - explicitKeepRecentEntries);
637
+ firstKeptEntry = compactedCount > 0 ? entries[compactedCount] : undefined;
638
+ compacted = compactedCount > 0 ? entries.slice(0, compactedCount) : [];
639
+ }
640
+ else {
641
+ const effectiveKeepTokens = explicitKeepRecentTokens ?? 8_000;
642
+ prep = prepareCompaction(entries, effectiveKeepTokens);
643
+ if (prep && prep.firstKeptIndex > 0) {
644
+ compacted = [...prep.messagesToSummarize, ...prep.turnPrefixMessages];
645
+ firstKeptEntry = entries[prep.firstKeptIndex];
646
+ }
647
+ }
648
+ const fileRefs = prep ? prep.fileRefs : collectFileReferences(compacted);
649
+ const summaryInput = prep ?? {
650
+ firstKeptIndex: firstKeptEntry ? entries.indexOf(firstKeptEntry) : 0,
651
+ messagesToSummarize: compacted,
652
+ turnPrefixMessages: [],
653
+ isSplitTurn: false,
654
+ fileRefs,
655
+ };
656
+ const baseSummary = options.summary ?? (options.generateSummary
657
+ ? await this.generateCompactionSummary(summaryInput)
658
+ : summarizeEntries(compacted));
530
659
  const summary = appendFileReferenceTags(baseSummary, fileRefs);
660
+ const compactedEntries = compacted.length;
531
661
  const entry = await this.recordEntry('compaction', {
532
662
  summary,
533
663
  compactedEntries,
@@ -549,6 +679,7 @@ export class StubFabricSession {
549
679
  summary,
550
680
  compactedEntries,
551
681
  ...(firstKeptEntry ? { firstKeptEntryId: firstKeptEntry.id } : {}),
682
+ details: { readFiles: fileRefs.readFiles, modifiedFiles: fileRefs.modifiedFiles },
552
683
  };
553
684
  }
554
685
  async autoCompactIfNeeded(messages, role, options) {
@@ -559,18 +690,26 @@ export class StubFabricSession {
559
690
  return messages;
560
691
  const compactAtTokens = options.compactAtTokens ?? settings?.compactAtTokens;
561
692
  const reserveTokens = options.reserveTokens ?? settings?.reserveTokens;
562
- const keepRecentEntries = options.compactionKeepRecentEntries ?? settings?.keepRecentEntries ?? 20;
693
+ const keepRecentEntries = options.compactionKeepRecentEntries ?? settings?.keepRecentEntries;
563
694
  const contextWindowTokens = options.contextWindowTokens ?? this.resolveModelContextWindow(options);
564
695
  const budget = evaluateContextBudget(messages, {
565
696
  ...(contextWindowTokens !== undefined ? { contextWindowTokens } : {}),
566
697
  ...(compactAtTokens !== undefined ? { compactAtTokens } : {}),
567
698
  ...(reserveTokens !== undefined ? { reserveTokens } : {}),
699
+ ...(settings?.keepRecentTokens !== undefined ? { keepRecentTokens: settings.keepRecentTokens } : {}),
568
700
  });
569
701
  if (!budget.shouldCompact)
570
702
  return messages;
571
703
  const messagesBefore = messages.length;
572
704
  const tokensBefore = budget.estimatedTokens;
573
- await this.compact({ keepRecentEntries, generateSummary: true, reason: 'threshold', tokensBefore, messagesBefore });
705
+ const compactOpts = { generateSummary: true, reason: 'threshold', tokensBefore, messagesBefore };
706
+ if (keepRecentEntries !== undefined) {
707
+ compactOpts.keepRecentEntries = keepRecentEntries;
708
+ }
709
+ else {
710
+ compactOpts.keepRecentTokens = settings?.keepRecentTokens ?? budget.keepRecentTokens ?? 8_000;
711
+ }
712
+ await this.compact(compactOpts);
574
713
  const compacted = buildModelMessagesFromHistory(await this.history(), role);
575
714
  return compacted;
576
715
  }
@@ -591,23 +730,29 @@ export class StubFabricSession {
591
730
  return undefined;
592
731
  }
593
732
  }
594
- async generateCompactionSummary(entries) {
733
+ async generateCompactionSummary(prep) {
734
+ if (prep.isSplitTurn && prep.turnPrefixMessages.length > 0) {
735
+ const [historyResult, prefixResult] = await Promise.all([
736
+ this.summarizeEntrySet(prep.messagesToSummarize),
737
+ this.summarizeEntrySet(prep.turnPrefixMessages, TURN_PREFIX_SUMMARIZATION_PROMPT),
738
+ ]);
739
+ return `${historyResult}\n\n---\n\n**Turn Context (split turn):**\n\n${prefixResult}`;
740
+ }
741
+ return this.summarizeEntrySet(prep.messagesToSummarize);
742
+ }
743
+ async summarizeEntrySet(entries, prefixPrompt) {
595
744
  if (entries.length === 0)
596
745
  return summarizeEntries(entries);
597
746
  const deterministic = summarizeEntries(entries);
598
747
  const provider = this.options.modelProvider ?? defaultModelProvider;
599
748
  try {
749
+ const serialized = JSON.stringify(entries.map((entry) => ({ type: entry.type, timestamp: entry.timestamp, data: entry.data ?? {} })).slice(0, 200));
750
+ const userContent = prefixPrompt ? `${serialized}\n\n${prefixPrompt}` : serialized;
600
751
  const response = await generateWithRuntime(provider, {
601
752
  ...(this.agent.model !== undefined ? { model: this.agent.model } : {}),
602
753
  messages: [
603
- {
604
- role: 'system',
605
- content: 'Summarize durable agent session history for future context. Preserve user goals, decisions, tool/command effects, files touched, errors, policy/approval events, and pending work. Do not include secrets.',
606
- },
607
- {
608
- role: 'user',
609
- content: JSON.stringify(entries.map((entry) => ({ type: entry.type, timestamp: entry.timestamp, data: entry.data ?? {} })).slice(0, 200)),
610
- },
754
+ { role: 'system', content: COMPACTION_SUMMARIZATION_SYSTEM_PROMPT },
755
+ { role: 'user', content: userContent },
611
756
  ],
612
757
  }, { retries: 1, retryDelayMs: 250 });
613
758
  return response.message?.content?.trim() || deterministic;
@@ -709,7 +854,7 @@ export class StubFabricSession {
709
854
  const scoped = createScopedSandboxEnv(env, this.options.cwd);
710
855
  return wrapWithPolicy(scoped, this.options.policy, this.options.bypassPolicyEnforcement);
711
856
  }
712
- async executeToolCalls(calls, tools, sandbox, callPolicy, approvalTimeoutMs, approvalHandler) {
857
+ async executeToolCalls(calls, tools, sandbox, callPolicy, approvalTimeoutMs, approvalHandler, attribution) {
713
858
  const results = [];
714
859
  const policy = callPolicy ?? this.options.policy;
715
860
  for (const call of calls) {
@@ -781,13 +926,13 @@ export class StubFabricSession {
781
926
  continue;
782
927
  }
783
928
  }
784
- await this.recordEntry('tool_call', { tool: call.tool, input: call.input, ...(tool?.metadata?.effect ? { effect: tool.metadata.effect } : {}), ...(call.id ? { toolCallId: call.id } : {}) });
785
- await this.emit('tool_start', { tool: call.tool });
929
+ await this.recordEntry('tool_call', { tool: call.tool, input: call.input, ...(tool?.metadata?.effect ? { effect: tool.metadata.effect } : {}), ...(call.id ? { toolCallId: call.id } : {}), ...(attribution ? { attribution: attribution } : {}) });
930
+ await this.emit('tool_start', { tool: call.tool, ...(attribution ? { attribution: attribution } : {}) });
786
931
  if (!tool?.execute) {
787
932
  const error = `Tool is not available: ${call.tool}`;
788
933
  results.push({ tool: call.tool, input: call.input, error, ...(call.id ? { id: call.id } : {}) });
789
934
  await this.recordEntry('error', { tool: call.tool, error, approvalRequired: false });
790
- await this.emit('tool_end', { tool: call.tool, error });
935
+ await this.emit('tool_end', { tool: call.tool, error, ...(attribution ? { attribution: attribution } : {}) });
791
936
  continue;
792
937
  }
793
938
  try {
@@ -796,14 +941,14 @@ export class StubFabricSession {
796
941
  const output = await tool.execute(input, { sandbox });
797
942
  const durationMs = Date.now() - startedAt;
798
943
  results.push({ tool: call.tool, input, output, ...(call.id ? { id: call.id } : {}) });
799
- await this.recordEntry('tool_result', { tool: call.tool, output: output, durationMs, ...(call.id ? { toolCallId: call.id } : {}) });
800
- await this.emit('tool_end', { tool: call.tool, durationMs });
944
+ await this.recordEntry('tool_result', { tool: call.tool, output: output, durationMs, ...(call.id ? { toolCallId: call.id } : {}), ...(attribution ? { attribution: attribution } : {}) });
945
+ await this.emit('tool_end', { tool: call.tool, durationMs, ...(attribution ? { attribution: attribution } : {}) });
801
946
  }
802
947
  catch (error) {
803
948
  const message = error instanceof Error ? error.message : String(error);
804
949
  results.push({ tool: call.tool, input: call.input, error: message, ...(call.id ? { id: call.id } : {}) });
805
950
  await this.recordEntry('error', { tool: call.tool, error: message });
806
- await this.emit('tool_end', { tool: call.tool, error: message });
951
+ await this.emit('tool_end', { tool: call.tool, error: message, ...(attribution ? { attribution: attribution } : {}) });
807
952
  }
808
953
  }
809
954
  return results;
@@ -929,7 +1074,7 @@ export class StubFabricSession {
929
1074
  resolveTools(sandbox, promptTools = [], promptCommands = []) {
930
1075
  const tools = new Map();
931
1076
  const commandToolOptions = this.options.resolveSecret ? { resolveSecret: this.options.resolveSecret } : {};
932
- for (const tool of createBuiltinTools(sandbox))
1077
+ for (const tool of createBuiltinTools(sandbox, this.options.packagedSkills))
933
1078
  tools.set(tool.name, tool);
934
1079
  for (const tool of createCommandTools(this.options.commands ?? [], commandToolOptions))
935
1080
  tools.set(tool.name, tool);
@@ -939,8 +1084,40 @@ export class StubFabricSession {
939
1084
  tools.set(tool.name, tool);
940
1085
  for (const tool of promptTools)
941
1086
  tools.set(tool.name, tool);
1087
+ const skills = this.options.skills ?? [];
1088
+ if (skills.length > 0) {
1089
+ const skillNames = skills.map((skill) => skill.name);
1090
+ const activateSkillTool = createActivateSkillTool(skillNames, (name) => this.activateSkillForTool(name));
1091
+ tools.set(activateSkillTool.name, activateSkillTool);
1092
+ }
942
1093
  return tools;
943
1094
  }
1095
+ async activateSkillForTool(name) {
1096
+ const cached = this.skillCache.get(name);
1097
+ if (cached !== undefined)
1098
+ return cached;
1099
+ const skill = this.resolveSkill(name);
1100
+ if (!skill)
1101
+ return `Error: Skill "${name}" not found.`;
1102
+ let content = skill.content;
1103
+ if (content === undefined && skill.path) {
1104
+ try {
1105
+ const sandbox = await this.sandbox;
1106
+ content = await sandbox.readFile(skill.path);
1107
+ }
1108
+ catch {
1109
+ return `Error: Skill "${name}" found but could not read content from path "${skill.path}".`;
1110
+ }
1111
+ }
1112
+ if (content === undefined) {
1113
+ return `Error: Skill "${name}" found but has no content or readable path.`;
1114
+ }
1115
+ if (skill.packaged) {
1116
+ content += formatPackagedSkillResourceList(skill.packaged);
1117
+ }
1118
+ this.skillCache.set(name, content);
1119
+ return content;
1120
+ }
944
1121
  resolveSkill(name) {
945
1122
  const normalized = normalizeSkillReference(name);
946
1123
  return this.options.skills?.find((skill) => {
@@ -986,10 +1163,22 @@ export class StubFabricSession {
986
1163
  retryable: false,
987
1164
  });
988
1165
  }
1166
+ if (!existing.affinityKey) {
1167
+ existing.affinityKey = generateAffinityKey(this.agent.id, this.id);
1168
+ await this.store.save(existing);
1169
+ }
989
1170
  return existing;
990
1171
  }
991
1172
  const now = new Date().toISOString();
992
- const data = { id: this.id, agentId: this.agent.id, createdAt: now, updatedAt: now, events: [], entries: [] };
1173
+ const data = {
1174
+ id: this.id,
1175
+ agentId: this.agent.id,
1176
+ createdAt: now,
1177
+ updatedAt: now,
1178
+ events: [],
1179
+ entries: [],
1180
+ affinityKey: generateAffinityKey(this.agent.id, this.id),
1181
+ };
993
1182
  if (this.options.metadata !== undefined)
994
1183
  data.metadata = this.options.metadata;
995
1184
  if (this.options.tenantId !== undefined)
@@ -997,6 +1186,61 @@ export class StubFabricSession {
997
1186
  await this.store.save(data);
998
1187
  return data;
999
1188
  }
1189
+ /**
1190
+ * Settle dangling state left by an interrupted turn before applying new
1191
+ * input (v2 A2 — port of flue's repairTrailingPartialToolBatch).
1192
+ *
1193
+ * A crash/abort between recording a `tool_call` and its outcome leaves an
1194
+ * assistant tool_use with no tool_result, which providers reject on the
1195
+ * next turn. For each trailing dangling call this appends a synthetic
1196
+ * `tool_result` marked interrupted (never re-executes the tool — the real
1197
+ * outcome is unknown), settles trailing unfinished `task_start`s, and
1198
+ * appends one advisory `signal` entry so the model knows the previous turn
1199
+ * was cut short. Idempotent: a repaired window has no dangling calls.
1200
+ */
1201
+ async repairDanglingToolCalls() {
1202
+ const data = await this.history();
1203
+ const path = SessionHistory.fromData(data).getActivePath();
1204
+ const dangling = findTrailingDanglingToolCalls(path);
1205
+ const unfinishedTasks = findTrailingUnfinishedTasks(path);
1206
+ if (dangling.length === 0 && unfinishedTasks.length === 0)
1207
+ return;
1208
+ for (const call of dangling) {
1209
+ const tool = typeof call.data?.tool === 'string' ? call.data.tool : 'unknown';
1210
+ const toolCallId = typeof call.data?.toolCallId === 'string' ? call.data.toolCallId : undefined;
1211
+ await this.recordEntry('tool_result', {
1212
+ tool,
1213
+ ...(toolCallId ? { toolCallId } : {}),
1214
+ output: {
1215
+ type: 'interrupted',
1216
+ message: 'Tool execution was interrupted before completion. The outcome is unknown.',
1217
+ },
1218
+ isError: true,
1219
+ interrupted: true,
1220
+ synthetic: true,
1221
+ });
1222
+ await this.emit('tool_end', { tool, error: 'interrupted', synthetic: true });
1223
+ }
1224
+ for (const task of unfinishedTasks) {
1225
+ const taskId = typeof task.data?.taskId === 'string' ? task.data.taskId : 'unknown';
1226
+ await this.recordEntry('task_end', {
1227
+ taskId,
1228
+ status: 'interrupted',
1229
+ synthetic: true,
1230
+ // Keep the child transcript locatable from the settled record.
1231
+ ...(typeof task.data?.agent === 'string' ? { agent: task.data.agent } : {}),
1232
+ });
1233
+ }
1234
+ if (dangling.length > 0) {
1235
+ const tools = dangling
1236
+ .map((call) => (typeof call.data?.tool === 'string' ? call.data.tool : 'unknown'))
1237
+ .join(', ');
1238
+ await this.recordEntry('signal', {
1239
+ signalType: 'interrupted',
1240
+ content: `The previous turn was interrupted; ${dangling.length} tool call(s) did not complete and may or may not have taken effect: ${tools}. Verify their effects before repeating side-effecting operations.`,
1241
+ });
1242
+ }
1243
+ }
1000
1244
  async recordEntry(type, data) {
1001
1245
  await this.ensureData();
1002
1246
  const current = await this.store.load(this.id);
@@ -1069,6 +1313,20 @@ function isJsonValue(value) {
1069
1313
  return false;
1070
1314
  return Object.values(value).every(isJsonValue);
1071
1315
  }
1316
+ function redactEnvValues(text, env) {
1317
+ if (!env)
1318
+ return text;
1319
+ let result = text;
1320
+ for (const [key, value] of Object.entries(env)) {
1321
+ if (key.length > 0) {
1322
+ result = result.replaceAll(key, '[REDACTED]');
1323
+ }
1324
+ if (value.length > 0) {
1325
+ result = result.replaceAll(value, '[REDACTED]');
1326
+ }
1327
+ }
1328
+ return result;
1329
+ }
1072
1330
  function summarizeEntries(entries) {
1073
1331
  if (entries.length === 0)
1074
1332
  return 'No entries to compact.';
@@ -1108,6 +1366,77 @@ function appendFileReferenceTags(summary, refs) {
1108
1366
  sections.push(`<modified-files>\n${refs.modifiedFiles.join('\n')}\n</modified-files>`);
1109
1367
  return sections.join('\n\n');
1110
1368
  }
1369
+ /** Valid cut points: user prompts and assistant messages. Never cut at tool_result. */
1370
+ function findValidCutPoints(entries, start, end) {
1371
+ const cutPoints = [];
1372
+ for (let i = start; i < end; i++) {
1373
+ const type = entries[i]?.type;
1374
+ if (type === 'user_prompt' || type === 'assistant_message') {
1375
+ cutPoints.push(i);
1376
+ }
1377
+ }
1378
+ return cutPoints;
1379
+ }
1380
+ function findTurnStartIndex(entries, index, start) {
1381
+ for (let i = index; i >= start; i--) {
1382
+ if (entries[i]?.type === 'user_prompt')
1383
+ return i;
1384
+ }
1385
+ return -1;
1386
+ }
1387
+ function findCutPoint(entries, start, end, keepRecentTokens) {
1388
+ const cutPoints = findValidCutPoints(entries, start, end);
1389
+ if (cutPoints.length === 0) {
1390
+ return { firstKeptIndex: start, turnStartIndex: -1, isSplitTurn: false };
1391
+ }
1392
+ let accumulatedTokens = 0;
1393
+ let firstKeptIndex = start;
1394
+ for (let i = end - 1; i >= start; i--) {
1395
+ const entry = entries[i];
1396
+ if (!entry)
1397
+ continue;
1398
+ accumulatedTokens += estimateSessionEntryTokens(entry);
1399
+ if (accumulatedTokens >= keepRecentTokens) {
1400
+ for (const cutPoint of cutPoints) {
1401
+ if (cutPoint >= i) {
1402
+ firstKeptIndex = cutPoint;
1403
+ break;
1404
+ }
1405
+ }
1406
+ break;
1407
+ }
1408
+ }
1409
+ const isUserMessage = entries[firstKeptIndex]?.type === 'user_prompt';
1410
+ const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, firstKeptIndex, start);
1411
+ return {
1412
+ firstKeptIndex,
1413
+ turnStartIndex,
1414
+ isSplitTurn: !isUserMessage && turnStartIndex !== -1,
1415
+ };
1416
+ }
1417
+ /** Pure function — no I/O. Finds the token-based cut point, extracts messages to summarize, and tracks file ops. */
1418
+ export function prepareCompaction(entries, keepRecentTokens, previousCompaction) {
1419
+ if (entries.length === 0)
1420
+ return undefined;
1421
+ const boundaryStart = previousCompaction ? previousCompaction.firstKeptIndex : 0;
1422
+ const boundaryEnd = entries.length;
1423
+ const cutPoint = findCutPoint(entries, boundaryStart, boundaryEnd, keepRecentTokens);
1424
+ if (cutPoint.firstKeptIndex <= boundaryStart)
1425
+ return undefined;
1426
+ const historyEnd = cutPoint.isSplitTurn ? cutPoint.turnStartIndex : cutPoint.firstKeptIndex;
1427
+ const messagesToSummarize = entries.slice(boundaryStart, historyEnd);
1428
+ const turnPrefixMessages = cutPoint.isSplitTurn
1429
+ ? entries.slice(cutPoint.turnStartIndex, cutPoint.firstKeptIndex)
1430
+ : [];
1431
+ const fileRefs = collectFileReferences([...messagesToSummarize, ...turnPrefixMessages]);
1432
+ return {
1433
+ firstKeptIndex: cutPoint.firstKeptIndex,
1434
+ messagesToSummarize,
1435
+ turnPrefixMessages,
1436
+ isSplitTurn: cutPoint.isSplitTurn,
1437
+ fileRefs,
1438
+ };
1439
+ }
1111
1440
  function isReadOnlyFileTool(tool) {
1112
1441
  return tool !== undefined && ['read', 'read_buffer', 'stat', 'readdir', 'exists', 'grep', 'glob'].includes(tool);
1113
1442
  }