@qvac/harness 0.2.8 → 0.2.10

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 (76) hide show
  1. package/README.md +15 -7
  2. package/dist/harness.bundle +13 -12
  3. package/dist-lib/index.d.ts +2 -1
  4. package/dist-lib/index.js +2 -1
  5. package/dist-lib/lib/capabilities/completion.d.ts +36 -2
  6. package/dist-lib/lib/capabilities/completion.js +150 -27
  7. package/dist-lib/lib/connect.js +3 -1
  8. package/dist-lib/lib/contract-types.d.ts +2 -0
  9. package/dist-lib/lib/docx/index.js +4 -27
  10. package/dist-lib/lib/hooks/built-in/index.d.ts +1 -0
  11. package/dist-lib/lib/hooks/built-in/index.js +1 -0
  12. package/dist-lib/lib/hooks/built-in/sandbox.d.ts +5 -0
  13. package/dist-lib/lib/hooks/built-in/sandbox.js +43 -0
  14. package/dist-lib/lib/hooks/built-in/skill-catalog.js +7 -2
  15. package/dist-lib/lib/hooks/registry.js +2 -1
  16. package/dist-lib/lib/hooks/types.d.ts +1 -0
  17. package/dist-lib/lib/office/index.d.ts +6 -0
  18. package/dist-lib/lib/office/index.js +38 -0
  19. package/dist-lib/lib/office/pptx.d.ts +1 -0
  20. package/dist-lib/lib/office/pptx.js +26 -0
  21. package/dist-lib/lib/office/python.d.ts +7 -0
  22. package/dist-lib/lib/office/python.js +32 -0
  23. package/dist-lib/lib/office/xlsx.d.ts +1 -0
  24. package/dist-lib/lib/office/xlsx.js +26 -0
  25. package/dist-lib/lib/serve.js +1 -0
  26. package/dist-lib/lib/tools/edit-image.js +1 -1
  27. package/dist-lib/lib/tools/generate-image.js +1 -1
  28. package/dist-lib/lib/tools/generate-music.js +1 -1
  29. package/dist-lib/lib/tools/http-request.js +3 -3
  30. package/dist-lib/lib/tools/mcp-call.js +3 -3
  31. package/dist-lib/lib/tools/skill.js +4 -2
  32. package/dist-lib/lib/tools/skills/auth-resolver.d.ts +1 -0
  33. package/dist-lib/lib/tools/skills/auth-resolver.js +59 -29
  34. package/dist-lib/lib/tools/toolsets.d.ts +1 -0
  35. package/dist-lib/lib/tools/toolsets.js +1 -0
  36. package/dist-lib/lib/tools/weather-lookup.js +9 -3
  37. package/dist-lib/lib/tools/web-fetch.js +6 -3
  38. package/dist-lib/lib/tools/web-search.js +5 -2
  39. package/dist-lib/lib/wire.js +28 -0
  40. package/dist-lib/schema/harness.js +7 -3
  41. package/dist-lib/spec/hrpc/messages.js +28 -10
  42. package/dist-lib/spec/hyperschema/index.js +28 -10
  43. package/dist-lib/spec/hyperschema/schema.json +19 -1
  44. package/dist-lib/spec/hyperschema/types.d.ts +4 -2
  45. package/package.json +10 -6
  46. package/.claude/skills/asana/SKILL.md +0 -86
  47. package/.claude/skills/gh-stack/SKILL.md +0 -181
  48. package/.claude/skills/gh-stack/references/commands.md +0 -177
  49. package/.claude/skills/gh-stack/references/stack-design.md +0 -97
  50. package/.claude/skills/gh-stack/references/troubleshooting.md +0 -159
  51. package/.claude/skills/js-house-style/SKILL.md +0 -671
  52. package/.claude/skills/kvcache/SKILL.md +0 -171
  53. package/.claude/skills/pr-create/SKILL.md +0 -180
  54. package/.claude/skills/pr-review/SKILL.md +0 -414
  55. package/CLAUDE.md +0 -39
  56. package/docs/decisions/2026-05-29-kvcache-tool-turns.md +0 -75
  57. package/docs/decisions/2026-06-02-kvcache-tool-turns-reenabled.md +0 -127
  58. package/docs/decisions/2026-06-23-android-cpu-llm-defaults.md +0 -126
  59. package/docs/decisions/2026-07-29-assistant-owned-storage-layout.md +0 -88
  60. package/docs/decisions/2026-08-04-sidecar-orphan-reaping.md +0 -79
  61. package/docs/decisions/2026-08-11-pyodide-worker-thread.md +0 -68
  62. package/docs/decisions/2026-08-17-harness-hook-pipeline.md +0 -84
  63. package/docs/decisions/2026-08-17-harness-outbound-egress-guard.md +0 -176
  64. package/docs/decisions/2026-08-20-chat-prompt-prefix-is-stable.md +0 -95
  65. package/docs/decisions/2026-08-20-kvcache-static-placement.md +0 -109
  66. package/docs/decisions/2026-08-21-vendored-python-wheels.md +0 -85
  67. package/docs/decisions/2026-08-26-exec-is-not-gated-by-skills.md +0 -64
  68. package/docs/decisions/2026-08-27-writable-skills-overlay.md +0 -56
  69. package/docs/decisions/2026-09-01-skill-operation-manifests.md +0 -53
  70. package/docs/decisions/2026-09-01-usage-breakdown-is-measured.md +0 -78
  71. package/docs/decisions/2026-09-03-usage-breakdown-reported-by-the-harness.md +0 -136
  72. package/docs/decisions/2026-09-10-skill-wording-is-measured-offline.md +0 -63
  73. package/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md +0 -70
  74. package/docs/decisions/INDEX.md +0 -40
  75. package/docs/decisions/TEMPLATE.md +0 -32
  76. package/docs/kvcache-reference.md +0 -655
@@ -93,6 +93,7 @@ export interface CompletionContext {
93
93
  toolConfigs?: Record<string, Record<string, unknown>>;
94
94
  generationParams?: LlmGenerationParams;
95
95
  responseFormat?: ResponseFormat;
96
+ captureInferenceCalls?: boolean;
96
97
  grantedDirs?: string[];
97
98
  grantedDrives?: GrantedDrive[];
98
99
  driveOp?: DriveOpFn;
@@ -239,7 +240,7 @@ export declare class Harness extends ReadyResource {
239
240
  agent?: Agent;
240
241
  signal?: BareAbortSignal;
241
242
  }): Promise<void>;
242
- completion({ model, agent, messages, cacheKey, thinkingDefault, toolConfigs, generationParams, responseFormat, grantedDirs, grantedDrives, driveOp, grantedWriteDirs, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal }: CompletionContext): AsyncGenerator<CompletionEvent>;
243
+ completion({ model, agent, messages, cacheKey, thinkingDefault, toolConfigs, generationParams, responseFormat, captureInferenceCalls, grantedDirs, grantedDrives, driveOp, grantedWriteDirs, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal }: CompletionContext): AsyncGenerator<CompletionEvent>;
243
244
  private _makeRoomForCompletion;
244
245
  private _evictWedgedModel;
245
246
  private _evictAfterLeases;
package/dist-lib/index.js CHANGED
@@ -943,7 +943,7 @@ export class Harness extends ReadyResource {
943
943
  releaseModel(pool, key);
944
944
  }
945
945
  }
946
- async *completion({ model, agent, messages, cacheKey, thinkingDefault, toolConfigs, generationParams, responseFormat, grantedDirs, grantedDrives, driveOp, grantedWriteDirs, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal }) {
946
+ async *completion({ model, agent, messages, cacheKey, thinkingDefault, toolConfigs, generationParams, responseFormat, captureInferenceCalls, grantedDirs, grantedDrives, driveOp, grantedWriteDirs, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal }) {
947
947
  await this.ready();
948
948
  if (this.suspended)
949
949
  throw new Error('harness is suspended');
@@ -1016,6 +1016,7 @@ export class Harness extends ReadyResource {
1016
1016
  thinkingDefault,
1017
1017
  generationParams,
1018
1018
  responseFormat,
1019
+ captureInferenceCalls,
1019
1020
  messages: turns,
1020
1021
  tools: this._tools,
1021
1022
  skillCatalog: await this._completionSkills(agent?.id ?? null, model),
@@ -36,6 +36,7 @@ export interface Agent {
36
36
  imageHeight?: number | null;
37
37
  approvalMode?: 'ask' | 'skip-all' | null;
38
38
  disabledToolsets?: string[] | null;
39
+ sandboxMode?: boolean | null;
39
40
  }
40
41
  export interface ImageSettings {
41
42
  modelChoice?: string;
@@ -221,8 +222,40 @@ export type MetricsEvent = {
221
222
  ctxSize?: number | undefined;
222
223
  backends?: RunBackendUse[] | undefined;
223
224
  usage?: UsageBreakdown | undefined;
225
+ stopReason?: string | undefined;
224
226
  };
225
- export type CompletionEvent = TextEvent | ToolCallEvent | ToolResultEvent | ToolActivityEvent | ErrorEvent | AbortedEvent | MetricsEvent;
227
+ export interface InferenceCall {
228
+ round: number;
229
+ droppedTurns: number;
230
+ modelId: string;
231
+ messages: Message[];
232
+ tools: SdkTool[] | undefined;
233
+ generationParams: LlmGenerationParams | undefined;
234
+ responseFormat: ResponseFormat | undefined;
235
+ toolDialect: ToolDialect | undefined;
236
+ cacheKey: string | undefined;
237
+ promptPrefix: string;
238
+ }
239
+ export type InferenceCallEvent = {
240
+ type: 'inference-call';
241
+ call: InferenceCall;
242
+ };
243
+ export type InferenceSentEvent = {
244
+ type: 'inference-sent';
245
+ round: number;
246
+ droppedTurns: number;
247
+ messages: Message[] | undefined;
248
+ tokens: InferenceTokens | undefined;
249
+ outcome: 'completed' | 'overflowed' | 'failed';
250
+ reason: string | undefined;
251
+ };
252
+ export interface InferenceTokens {
253
+ prompt?: number | undefined;
254
+ generated?: number | undefined;
255
+ cached?: number | undefined;
256
+ timeToFirstToken?: number | undefined;
257
+ }
258
+ export type CompletionEvent = TextEvent | ToolCallEvent | ToolResultEvent | ToolActivityEvent | ErrorEvent | AbortedEvent | MetricsEvent | InferenceCallEvent | InferenceSentEvent;
226
259
  export interface CompletionSdkResult {
227
260
  requestId?: string;
228
261
  tokenStream: AsyncIterable<string>;
@@ -283,6 +316,7 @@ interface CompletionArgs {
283
316
  toolConfigs?: Record<string, Record<string, unknown>>;
284
317
  fsRoots?: readonly DriveRoot[];
285
318
  fsWriteRoots?: readonly DriveRoot[];
319
+ captureInferenceCalls?: boolean;
286
320
  }
287
321
  export declare const CONTEXT_OVERFLOW_MESSAGE = "This message is too large for the model context window.";
288
322
  export declare function trustedSourcesFrom(history: readonly Message[]): TrustedSource[];
@@ -290,5 +324,5 @@ export declare function toSdkTool(tool: ToolDefinition): SdkTool;
290
324
  export type CompletionSkillEntry = SkillCatalogEntry & {
291
325
  readonly status?: SkillState;
292
326
  };
293
- export declare function completion(sdk: CompletionSdk, { modelId, acquireRound, model, agent, ctxSize, cacheKey, thinkingDefault, generationParams: generationParamsOverride, responseFormat, messages: storedMessages, tools: allTools, skillCatalog, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal, toolConfigs, fsRoots, fsWriteRoots }: CompletionArgs): AsyncGenerator<CompletionEvent>;
327
+ export declare function completion(sdk: CompletionSdk, { modelId, acquireRound, model, agent, ctxSize, cacheKey, thinkingDefault, generationParams: generationParamsOverride, responseFormat, messages: storedMessages, tools: allTools, skillCatalog, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal, toolConfigs, fsRoots, fsWriteRoots, captureInferenceCalls }: CompletionArgs): AsyncGenerator<CompletionEvent>;
294
328
  export {};
@@ -24,7 +24,7 @@ import { collectToolGrants, enabledTools, isSelected, skillIsActive, skillsAreSe
24
24
  import { guardToolCall } from '../tools/outbound-guard.js';
25
25
  import { isAvailableState } from '../tools/skills/skill-state-constants.js';
26
26
  import { resolveImplicitSkill, resolveSkillMention, skillPreloadMessage } from '../tools/skills/mention.js';
27
- import { permittedTools } from '../tools/toolsets.js';
27
+ import { NETWORK_TOOLSETS, permittedTools } from '../tools/toolsets.js';
28
28
  import { promptPrefixHash } from './prompt-prefix.js';
29
29
  import { logToolPrompt } from './tool-prompt-log.js';
30
30
  import { DIRECT_CALL_ID, loadedSkillFilesFrom, loadedSkillsFrom, TOOL_CONTINUATION_NUDGE, toolResultTurn, withoutSkillLoadTurns } from './tool-turns.js';
@@ -157,7 +157,8 @@ function preloadLastUser(messages, name, body) {
157
157
  }
158
158
  return messages;
159
159
  }
160
- function collectStats(state, stats) {
160
+ function collectStats(state, stats, stopReason) {
161
+ state.stopReason = stopReason;
161
162
  if (!stats)
162
163
  return;
163
164
  if (state.ttft === undefined && stats.timeToFirstToken)
@@ -191,7 +192,8 @@ function metricsEvent(state, usage) {
191
192
  cacheTokens: state.cacheTokens,
192
193
  ...(state.ctxSize ? { ctxSize: state.ctxSize } : {}),
193
194
  ...(state.backends.length ? { backends: state.backends } : {}),
194
- ...(usage ? { usage } : {})
195
+ ...(usage ? { usage } : {}),
196
+ ...(state.stopReason ? { stopReason: state.stopReason } : {})
195
197
  };
196
198
  }
197
199
  const TOOL_DENIED_MESSAGE = 'The user declined this call. Do not retry it or call it with different arguments to get ' +
@@ -200,7 +202,7 @@ const TOOL_DENIED_MESSAGE = 'The user declined this call. Do not retry it or cal
200
202
  function deniedResult() {
201
203
  return { error: TOOL_DENIED_MESSAGE, denied: true };
202
204
  }
203
- function denialKey(name, args) {
205
+ function callKey(name, args) {
204
206
  try {
205
207
  return `${name}:${JSON.stringify(args)}`;
206
208
  }
@@ -208,6 +210,34 @@ function denialKey(name, args) {
208
210
  return name;
209
211
  }
210
212
  }
213
+ const RETRYABLE_STATUS = new Set([408, 429]);
214
+ const RETRYABLE_CODE = new Set(['timeout', 'transport', 'busy']);
215
+ function callSucceeded(result, outcome) {
216
+ if (outcome.kind !== 'success')
217
+ return false;
218
+ const exitCode = result?.exitCode;
219
+ return typeof exitCode !== 'number' || exitCode === 0;
220
+ }
221
+ function retryHints(result) {
222
+ if (!result || typeof result !== 'object' || Array.isArray(result))
223
+ return {};
224
+ const record = result;
225
+ return {
226
+ ...(typeof record.status === 'number' ? { status: record.status } : {}),
227
+ ...(typeof record.code === 'string' ? { code: record.code } : {})
228
+ };
229
+ }
230
+ function settledFailure(result, outcome) {
231
+ if (outcome.kind === 'denied' || callSucceeded(result, outcome))
232
+ return false;
233
+ if (outcome.kind === 'error' && outcome.error !== undefined)
234
+ return false;
235
+ const { code, status } = retryHints(result);
236
+ if (code !== undefined && RETRYABLE_CODE.has(code))
237
+ return false;
238
+ return status === undefined || (status < 500 && !RETRYABLE_STATUS.has(status));
239
+ }
240
+ const MAX_BLOCKED_REPEATS = 3;
211
241
  function terminalCall(result, outcome, options) {
212
242
  return { result, outcome, executed: options.executed };
213
243
  }
@@ -248,7 +278,7 @@ async function executeCall(tool, args, ctx, { approve, signal, denied, sources =
248
278
  });
249
279
  return blockedResult({ error: refusal, guidance: true }, refusal);
250
280
  }
251
- const key = denialKey(tool.name, args);
281
+ const key = callKey(tool.name, args);
252
282
  if (denied?.has(key)) {
253
283
  hlog('tool denied already, not asking again', { tool: tool.name });
254
284
  return deniedCall();
@@ -364,7 +394,7 @@ function blockedSkillMessage(name, catalog, listShown) {
364
394
  `skill is available this turn — do not guess another name, and answer from what you ` +
365
395
  `already have.`;
366
396
  }
367
- async function resolveToolCalls(toolCalls, enabled, agentId, pipeline, skillNames, signal) {
397
+ async function resolveToolCalls(toolCalls, enabled, agentId, pipeline, skillNames, { sandboxMode, signal }) {
368
398
  const resolved = [];
369
399
  for (const call of toolCalls) {
370
400
  if (signal?.aborted)
@@ -388,6 +418,7 @@ async function resolveToolCalls(toolCalls, enabled, agentId, pipeline, skillName
388
418
  agentId,
389
419
  skillNames,
390
420
  parameters: tool.parameters,
421
+ sandboxMode,
391
422
  signal
392
423
  });
393
424
  resolved.push(fold.ok
@@ -463,6 +494,21 @@ async function* runRoundTokens(sdk, result, state, signal, startInThink = false)
463
494
  await events.return(null);
464
495
  }
465
496
  }
497
+ function sentMessages(stats) {
498
+ const sent = stats?.sentMessages;
499
+ return Array.isArray(sent) ? sent : null;
500
+ }
501
+ function roundTokens(stats) {
502
+ if (!stats)
503
+ return undefined;
504
+ const tokens = {
505
+ prompt: stats.promptTokens,
506
+ generated: stats.generatedTokens,
507
+ cached: stats.cacheTokens,
508
+ timeToFirstToken: stats.timeToFirstToken
509
+ };
510
+ return Object.values(tokens).some((value) => value !== undefined) ? tokens : undefined;
511
+ }
466
512
  const TRUNCATED_FINAL_MS = 2000;
467
513
  function truncatedFinal(final, contentText) {
468
514
  const accepted = { contentText, toolCalls: [], raw: { fullText: contentText } };
@@ -496,17 +542,35 @@ async function* runRecoveredRoundWith(sdk, args, modelId) {
496
542
  let emitted = false;
497
543
  const state = { stopToken: null, unproven: null, rawContentText: '' };
498
544
  const cacheable = candidate.history === args.history;
545
+ const params = {
546
+ modelId,
547
+ history: projectVisionHistory(candidate.history),
548
+ stream: true,
549
+ tools: args.tools,
550
+ generationParams: args.generationParams,
551
+ responseFormat: args.responseFormat,
552
+ toolDialect: args.toolDialect,
553
+ kvCache: cacheable ? args.cacheKey : undefined
554
+ };
555
+ if (args.capture) {
556
+ yield {
557
+ type: 'inference-call',
558
+ call: {
559
+ round: args.round,
560
+ droppedTurns: candidate.droppedTurns,
561
+ modelId,
562
+ messages: params.history,
563
+ tools: params.tools,
564
+ generationParams: params.generationParams,
565
+ responseFormat: params.responseFormat,
566
+ toolDialect: params.toolDialect,
567
+ cacheKey: params.kvCache,
568
+ promptPrefix: args.promptPrefix
569
+ }
570
+ };
571
+ }
499
572
  try {
500
- const result = sdk.completion({
501
- modelId,
502
- history: projectVisionHistory(candidate.history),
503
- stream: true,
504
- tools: args.tools,
505
- generationParams: args.generationParams,
506
- ...(args.responseFormat ? { responseFormat: args.responseFormat } : {}),
507
- toolDialect: args.toolDialect,
508
- ...(cacheable && args.cacheKey ? { kvCache: args.cacheKey } : {})
509
- });
573
+ const result = sdk.completion(params);
510
574
  result.final.catch(noop);
511
575
  const startInThink = args.generationParams?.reasoning_budget === -1;
512
576
  for await (const event of runRoundTokens(sdk, result, state, args.signal, startInThink)) {
@@ -518,6 +582,17 @@ async function* runRecoveredRoundWith(sdk, args, modelId) {
518
582
  const final = state.stopToken
519
583
  ? await truncatedFinal(result.final, state.rawContentText)
520
584
  : await raceAbort(result.final, args.signal);
585
+ if (args.capture) {
586
+ yield {
587
+ type: 'inference-sent',
588
+ round: args.round,
589
+ droppedTurns: candidate.droppedTurns,
590
+ messages: sentMessages(final.stats) ?? undefined,
591
+ tokens: roundTokens(final.stats),
592
+ outcome: 'completed',
593
+ reason: undefined
594
+ };
595
+ }
521
596
  const replay = unprovenReplay(state, final);
522
597
  hlog('final', {
523
598
  stopReason: final.stopReason,
@@ -535,6 +610,17 @@ async function* runRecoveredRoundWith(sdk, args, modelId) {
535
610
  };
536
611
  }
537
612
  catch (err) {
613
+ if (args.capture) {
614
+ yield {
615
+ type: 'inference-sent',
616
+ round: args.round,
617
+ droppedTurns: candidate.droppedTurns,
618
+ messages: undefined,
619
+ tokens: undefined,
620
+ outcome: isContextOverflowError(err) ? 'overflowed' : 'failed',
621
+ reason: err instanceof Error ? err.message : String(err)
622
+ };
623
+ }
538
624
  if (!isContextOverflowError(err))
539
625
  throw err;
540
626
  if (emitted)
@@ -551,10 +637,13 @@ function* reportUnnamedWrites(said, landed) {
551
637
  if (unreported)
552
638
  yield { type: 'content', text: unreported };
553
639
  }
554
- export async function* completion(sdk, { modelId, acquireRound, model, agent, ctxSize, cacheKey, thinkingDefault, generationParams: generationParamsOverride, responseFormat, messages: storedMessages, tools: allTools = [], skillCatalog = [], approve, attach, fetchAttachment, ragSearch, progress, askUser, signal, toolConfigs, fsRoots = [], fsWriteRoots = [] }) {
640
+ export async function* completion(sdk, { modelId, acquireRound, model, agent, ctxSize, cacheKey, thinkingDefault, generationParams: generationParamsOverride, responseFormat, messages: storedMessages, tools: allTools = [], skillCatalog = [], approve, attach, fetchAttachment, ragSearch, progress, askUser, signal, toolConfigs, fsRoots = [], fsWriteRoots = [], captureInferenceCalls = false }) {
555
641
  const pipeline = new HookPipeline();
556
642
  const agentId = agent?.id ?? null;
557
- const tools = permittedTools(allTools, agent?.disabledToolsets);
643
+ const disabledToolsets = agent?.sandboxMode
644
+ ? [...(agent.disabledToolsets ?? []), ...NETWORK_TOOLSETS]
645
+ : agent?.disabledToolsets;
646
+ const tools = permittedTools(allTools, disabledToolsets);
558
647
  const writableRoots = [
559
648
  ...fsWriteRoots,
560
649
  ...fsRoots.filter((root) => root.kind === 'hyper' && root.writable && !fsWriteRoots.includes(root))
@@ -709,6 +798,8 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
709
798
  let roundCacheKey = stubDropped ? undefined : visionHistoryCacheKey(cacheKey, history);
710
799
  const runUsage = () => usageBreakdown(promptParts, stats.cacheTokens, model);
711
800
  const denied = new Set();
801
+ const failed = new Map();
802
+ let blockedRepeats = 0;
712
803
  let webSearches = 0;
713
804
  const sources = trustedSourcesFrom(messages);
714
805
  if (preloadedSkill)
@@ -789,7 +880,9 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
789
880
  let mute = 0;
790
881
  let narrated = false;
791
882
  let cappedRounds = 0;
792
- for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
883
+ let roundsSent = 0;
884
+ let round = 0;
885
+ for (; round < MAX_TOOL_ROUNDS; round++) {
793
886
  if (signal?.aborted)
794
887
  throw new AbortedError();
795
888
  if (Date.now() >= runBudget.deadlineMs) {
@@ -820,13 +913,17 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
820
913
  responseFormat,
821
914
  toolDialect,
822
915
  cacheKey: roundCacheKey,
823
- signal
916
+ signal,
917
+ round,
918
+ promptPrefix,
919
+ capture: captureInferenceCalls
824
920
  });
921
+ roundsSent = round + 1;
825
922
  const final = recovered.final;
826
923
  if (!recovered.cacheable)
827
924
  roundCacheKey = undefined;
828
925
  history = recovered.history;
829
- collectStats(stats, final.stats);
926
+ collectStats(stats, final.stats, final.stopReason);
830
927
  hlog('round stats', { round, wallMs: Date.now() - roundStarted, ...(final.stats ?? {}) });
831
928
  const markup = stripToolResponseBlocks(truncateAtControlToken(final.raw.fullText) || recovered.unproven || '');
832
929
  let toolCalls = final.toolCalls?.length
@@ -880,7 +977,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
880
977
  }
881
978
  mute = 0;
882
979
  narrated = false;
883
- const resolved = await resolveToolCalls(toolCalls, enabled, agent?.id ?? null, pipeline, inContextSkills.map((skill) => skill.name), signal);
980
+ const resolved = await resolveToolCalls(toolCalls, enabled, agent?.id ?? null, pipeline, inContextSkills.map((skill) => skill.name), { sandboxMode: agent?.sandboxMode === true, signal });
884
981
  if (signal?.aborted)
885
982
  throw new AbortedError();
886
983
  history = [
@@ -891,10 +988,12 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
891
988
  }
892
989
  ];
893
990
  let cappedCalls = 0;
991
+ let progressed = false;
894
992
  runOutcomes.beginRound();
895
993
  for (const { call, tool, args, rawCall, rejection, intent } of resolved) {
896
994
  if (signal?.aborted)
897
995
  throw new AbortedError();
996
+ const priorFailure = failed.get(callKey(call.name, args));
898
997
  const searchCapped = tool?.name === WEB_SEARCH_TOOL_NAME && webSearches >= MAX_WEB_SEARCHES_PER_TURN;
899
998
  const callCtx = {
900
999
  ...toolContext(turnImages, ctxSize),
@@ -912,7 +1011,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
912
1011
  const resource = tool?.resource?.(args) ?? null;
913
1012
  const skillName = unquoteArg(args?.name ?? args?.skill_name ?? args?.skill);
914
1013
  const skillReloaded = tool?.name === SKILL_TOOL_NAME && !args?.file && loadedThisTurn.has(skillName);
915
- const silent = searchCapped || skillReloaded;
1014
+ const silent = searchCapped || skillReloaded || priorFailure !== undefined;
916
1015
  const display = silent
917
1016
  ? undefined
918
1017
  : labelOf(call.name, args, skillCall ? skillEmoji(skillName) : skillIcon);
@@ -926,7 +1025,15 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
926
1025
  const unreadyReason = `skill "${skillName}" is ${unreadyState ?? 'unavailable'} and cannot be used yet. ` +
927
1026
  `Tell the user to open it in Skills and finish its setup; do not retry.`;
928
1027
  let execution;
929
- if (skillReloaded) {
1028
+ if (priorFailure) {
1029
+ blockedRepeats++;
1030
+ hlog('repeat of a call that already failed; answered without running it', {
1031
+ tool: call.name,
1032
+ blockedRepeats
1033
+ });
1034
+ execution = terminalCall(priorFailure.result, priorFailure.outcome, { executed: false });
1035
+ }
1036
+ else if (skillReloaded) {
930
1037
  execution = terminalCall({
931
1038
  name: skillName,
932
1039
  content: `${skillName} is already loaded. Follow the instructions you were given and do not load it again.`
@@ -974,13 +1081,20 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
974
1081
  });
975
1082
  const output = settled.result;
976
1083
  const executionOutcome = settled.outcome;
1084
+ if (!priorFailure && settledFailure(output, executionOutcome)) {
1085
+ failed.set(callKey(call.name, args), settled);
1086
+ }
1087
+ else if (execution.executed && callSucceeded(output, executionOutcome)) {
1088
+ progressed = true;
1089
+ }
977
1090
  runOutcomes.record(output, executionOutcome);
978
1091
  if (searchCapped) {
979
1092
  cappedCalls++;
980
1093
  hlog('web_search capped', { searches: webSearches });
981
1094
  }
982
- else if (tool?.name === WEB_SEARCH_TOOL_NAME && !rejection)
1095
+ else if (tool?.name === WEB_SEARCH_TOOL_NAME && !rejection && !priorFailure) {
983
1096
  webSearches++;
1097
+ }
984
1098
  if (call.name === SKILL_TOOL_NAME &&
985
1099
  output &&
986
1100
  typeof output === 'object' &&
@@ -1032,7 +1146,13 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1032
1146
  history.push({ role: 'tool', content: turn });
1033
1147
  }
1034
1148
  runOutcomes.finishRound();
1149
+ if (progressed) {
1150
+ failed.clear();
1151
+ blockedRepeats = 0;
1152
+ }
1035
1153
  history.push({ role: 'user', content: TOOL_CONTINUATION_NUDGE });
1154
+ if (blockedRepeats >= MAX_BLOCKED_REPEATS)
1155
+ break;
1036
1156
  if (cappedCalls === resolved.length) {
1037
1157
  if (cappedRounds++ >= MAX_CAPPED_ROUNDS) {
1038
1158
  outcome = 'search-capped';
@@ -1060,9 +1180,12 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1060
1180
  tools: undefined,
1061
1181
  generationParams,
1062
1182
  cacheKey: roundCacheKey,
1063
- signal
1183
+ signal,
1184
+ round: roundsSent,
1185
+ promptPrefix: promptPrefixHash({ system: systemContent, tools: undefined }),
1186
+ capture: captureInferenceCalls
1064
1187
  });
1065
- collectStats(stats, recovered.final.stats);
1188
+ collectStats(stats, recovered.final.stats, recovered.final.stopReason);
1066
1189
  const answer = recovered.replay ? stripToolCallMarkup(recovered.replay) : '';
1067
1190
  if (answer)
1068
1191
  yield { type: 'content', text: answer };
@@ -112,6 +112,7 @@ export class RemoteHarness extends EventEmitter {
112
112
  thinkingDefault: context.thinkingDefault,
113
113
  generationParams: context.generationParams,
114
114
  responseFormat: context.responseFormat,
115
+ captureInferenceCalls: context.captureInferenceCalls,
115
116
  messages: context.messages.map(({ role, content, attachments }) => ({
116
117
  role,
117
118
  content,
@@ -655,7 +656,8 @@ function knobs(agent) {
655
656
  imageWidth: agent.imageWidth ?? undefined,
656
657
  imageHeight: agent.imageHeight ?? undefined,
657
658
  approvalMode: agent.approvalMode ?? undefined,
658
- disabledToolsets: agent.disabledToolsets ?? undefined
659
+ disabledToolsets: agent.disabledToolsets ?? undefined,
660
+ sandboxMode: agent.sandboxMode ?? undefined
659
661
  };
660
662
  }
661
663
  async function bridgeAttach(event, attach) {
@@ -38,6 +38,7 @@ export interface AgentKnobs {
38
38
  imageHeight?: number | null;
39
39
  approvalMode?: 'ask' | 'skip-all' | null;
40
40
  disabledToolsets?: string[] | null;
41
+ sandboxMode?: boolean | null;
41
42
  }
42
43
  export interface CompletionContext {
43
44
  model: string;
@@ -48,6 +49,7 @@ export interface CompletionContext {
48
49
  toolConfigs?: Record<string, Record<string, unknown>>;
49
50
  generationParams?: LlmGenerationParams;
50
51
  responseFormat?: ResponseFormat;
52
+ captureInferenceCalls?: boolean;
51
53
  grantedDirs?: string[];
52
54
  grantedDrives?: GrantedDrive[];
53
55
  driveOp?: DriveOpFn;
@@ -1,9 +1,5 @@
1
- import b4a from 'b4a';
2
- import fs from 'fs';
3
- import { runPython } from '../tools/exec/adapters/python/pyodide.js';
4
- import { planVendoredInstall, vendoredManifest, wheelsDir } from '../tools/exec/adapters/python/wheels.js';
1
+ import { extractWithPython } from '../office/python.js';
5
2
  export { closePythonWorker as closeDocxWorker } from '../tools/exec/adapters/python/worker/client.js';
6
- const MAX_OFFICE_BYTES = 20 * 1024 * 1024;
7
3
  const PACKAGES = ['python-docx==1.2.0'];
8
4
  const EXTRACT = `from docx import Document
9
5
  from docx.text.paragraph import Paragraph
@@ -21,29 +17,10 @@ def text(container):
21
17
  Path("extracted.txt").write_text("\\n".join(text(Document("input.docx"))), encoding="utf-8")
22
18
  `;
23
19
  export async function extractDocxText(bytes, fileName, options = {}) {
24
- if (bytes.byteLength > MAX_OFFICE_BYTES) {
25
- throw new Error(`document is too large to extract: ${bytes.byteLength} bytes, limit ${MAX_OFFICE_BYTES}`);
26
- }
27
- const dir = wheelsDir();
28
- const manifest = vendoredManifest(dir);
29
- const install = planVendoredInstall(PACKAGES, { dir, manifest });
30
- if (!dir ||
31
- !manifest ||
32
- !install ||
33
- !['micropip', ...install.packages].every((name) => Object.keys(manifest.files).some((file) => file.replaceAll('_', '-').startsWith(`${name}-`) && fs.existsSync(`${dir}/${file}`)))) {
34
- throw new Error('bundled DOCX extraction packages are unavailable');
35
- }
36
- const result = await runPython(EXTRACT, {
20
+ return extractWithPython(bytes, fileName, {
21
+ format: 'DOCX',
37
22
  packages: PACKAGES,
38
- inputs: [{ path: 'input.docx', bytes }],
39
- outputs: ['extracted.txt'],
23
+ source: EXTRACT,
40
24
  signal: options.signal
41
25
  });
42
- if (result.exitCode !== 0)
43
- throw new Error(`${fileName}: ${result.stderr.trim()}`);
44
- const output = result.files?.get('extracted.txt');
45
- if (!output) {
46
- throw new Error(result.rejected?.[0]?.reason ?? 'DOCX extraction produced no text file');
47
- }
48
- return b4a.toString(output, 'utf8');
49
26
  }
@@ -1,3 +1,4 @@
1
1
  export { ArgRepairHook } from './arg-repair.ts';
2
2
  export { EgressGuardHook } from './egress.ts';
3
+ export { SandboxHook } from './sandbox.ts';
3
4
  export { SkillCatalogGuardHook } from './skill-catalog.ts';
@@ -1,3 +1,4 @@
1
1
  export { ArgRepairHook } from './arg-repair.js';
2
2
  export { EgressGuardHook } from './egress.js';
3
+ export { SandboxHook } from './sandbox.js';
3
4
  export { SkillCatalogGuardHook } from './skill-catalog.js';
@@ -0,0 +1,5 @@
1
+ import type { HarnessHook, HookContext, HookResult } from '../types.ts';
2
+ export declare class SandboxHook implements HarnessHook {
3
+ readonly phase: 'preToolCall';
4
+ run(ctx: HookContext): HookResult;
5
+ }
@@ -0,0 +1,43 @@
1
+ import { bareBin, firstCommandToken } from '../../tools/exec/scope.js';
2
+ const NETWORK_BINS = new Set([
3
+ 'curl',
4
+ 'wget',
5
+ 'nc',
6
+ 'ncat',
7
+ 'netcat',
8
+ 'ssh',
9
+ 'scp',
10
+ 'sftp',
11
+ 'rsync',
12
+ 'ftp',
13
+ 'telnet',
14
+ 'git',
15
+ 'npm',
16
+ 'npx',
17
+ 'pnpm',
18
+ 'yarn',
19
+ 'bun',
20
+ 'pip',
21
+ 'pip3',
22
+ 'brew',
23
+ 'dig',
24
+ 'nslookup',
25
+ 'ping',
26
+ 'openssl'
27
+ ]);
28
+ export class SandboxHook {
29
+ phase = 'preToolCall';
30
+ run(ctx) {
31
+ if (ctx.phase !== 'preToolCall')
32
+ return;
33
+ if (!ctx.sandboxMode || ctx.toolName !== 'exec')
34
+ return;
35
+ if (Array.isArray(ctx.args.packages) && ctx.args.packages.length) {
36
+ return { error: 'sandbox mode: installing packages reaches the network', guidance: true };
37
+ }
38
+ const bin = bareBin(firstCommandToken(String(ctx.args.command ?? '')).replace(/^.*[/\\]/, ''));
39
+ if (bin && NETWORK_BINS.has(bin)) {
40
+ return { error: `sandbox mode: ${bin} reaches the network`, guidance: true };
41
+ }
42
+ }
43
+ }
@@ -11,7 +11,12 @@ export class SkillCatalogGuardHook {
11
11
  const known = new Set(entries.map((entry) => (typeof entry === 'string' ? entry : entry.name)));
12
12
  const raw = ctx.args.name ?? ctx.args.skill_name ?? ctx.args.skill;
13
13
  const name = typeof raw === 'string' ? raw : '';
14
- if (!known.has(name))
15
- return { error: `skill not found: ${name}`, guidance: true };
14
+ if (known.has(name))
15
+ return;
16
+ const listed = [...known].join(', ');
17
+ const rest = listed
18
+ ? `The only skills are: ${listed}. Nothing else needs a skill load — answer or act with the tools you already have.`
19
+ : 'No skill is available this turn — answer or act with the tools you already have.';
20
+ return { error: `skill not found: ${name}. ${rest}`, guidance: true };
16
21
  }
17
22
  }
@@ -1,6 +1,7 @@
1
- import { ArgRepairHook, EgressGuardHook, SkillCatalogGuardHook } from './built-in/index.js';
1
+ import { ArgRepairHook, EgressGuardHook, SandboxHook, SkillCatalogGuardHook } from './built-in/index.js';
2
2
  export const harnessHooks = [
3
3
  new ArgRepairHook(),
4
4
  new EgressGuardHook(),
5
+ new SandboxHook(),
5
6
  new SkillCatalogGuardHook()
6
7
  ];
@@ -14,6 +14,7 @@ export interface PreToolCallPayload {
14
14
  readonly signal?: BareAbortSignal | undefined;
15
15
  readonly skillNames?: readonly string[];
16
16
  readonly parameters?: ToolDefinition['parameters'];
17
+ readonly sandboxMode?: boolean;
17
18
  args: Record<string, unknown>;
18
19
  }
19
20
  export interface PostToolCallPayload {
@@ -0,0 +1,6 @@
1
+ import type { AbortSignal } from 'bare-abort-controller';
2
+ export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/client.ts';
3
+ export declare function extractOfficeText(bytes: Uint8Array, fileName: string, options?: {
4
+ readonly signal?: AbortSignal | undefined;
5
+ readonly mimeType?: string | undefined;
6
+ }): Promise<string>;