@qvac/harness 0.2.9 → 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 (42) hide show
  1. package/README.md +15 -7
  2. package/dist/harness.bundle +7 -59
  3. package/dist/prebuilds-manifest.json +0 -8
  4. package/dist-lib/lib/capabilities/completion.d.ts +1 -0
  5. package/dist-lib/lib/capabilities/completion.js +66 -9
  6. package/dist-lib/lib/connect.js +2 -1
  7. package/dist-lib/lib/contract-types.d.ts +1 -0
  8. package/dist-lib/lib/docx/index.js +4 -27
  9. package/dist-lib/lib/hooks/built-in/index.d.ts +1 -0
  10. package/dist-lib/lib/hooks/built-in/index.js +1 -0
  11. package/dist-lib/lib/hooks/built-in/sandbox.d.ts +5 -0
  12. package/dist-lib/lib/hooks/built-in/sandbox.js +43 -0
  13. package/dist-lib/lib/hooks/built-in/skill-catalog.js +7 -2
  14. package/dist-lib/lib/hooks/registry.js +2 -1
  15. package/dist-lib/lib/hooks/types.d.ts +1 -0
  16. package/dist-lib/lib/office/index.d.ts +6 -0
  17. package/dist-lib/lib/office/index.js +38 -0
  18. package/dist-lib/lib/office/pptx.d.ts +1 -0
  19. package/dist-lib/lib/office/pptx.js +26 -0
  20. package/dist-lib/lib/office/python.d.ts +7 -0
  21. package/dist-lib/lib/office/python.js +32 -0
  22. package/dist-lib/lib/office/xlsx.d.ts +1 -0
  23. package/dist-lib/lib/office/xlsx.js +26 -0
  24. package/dist-lib/lib/tools/edit-image.js +1 -1
  25. package/dist-lib/lib/tools/generate-image.js +1 -1
  26. package/dist-lib/lib/tools/generate-music.js +1 -1
  27. package/dist-lib/lib/tools/http-request.js +3 -3
  28. package/dist-lib/lib/tools/mcp-call.js +3 -3
  29. package/dist-lib/lib/tools/skill.js +4 -2
  30. package/dist-lib/lib/tools/skills/auth-resolver.d.ts +1 -0
  31. package/dist-lib/lib/tools/skills/auth-resolver.js +59 -29
  32. package/dist-lib/lib/tools/toolsets.d.ts +1 -0
  33. package/dist-lib/lib/tools/toolsets.js +1 -0
  34. package/dist-lib/lib/tools/weather-lookup.js +9 -3
  35. package/dist-lib/lib/tools/web-fetch.js +6 -3
  36. package/dist-lib/lib/tools/web-search.js +5 -2
  37. package/dist-lib/schema/harness.js +2 -1
  38. package/dist-lib/spec/hrpc/messages.js +8 -5
  39. package/dist-lib/spec/hyperschema/index.js +8 -5
  40. package/dist-lib/spec/hyperschema/schema.json +6 -1
  41. package/dist-lib/spec/hyperschema/types.d.ts +2 -1
  42. package/package.json +5 -1
@@ -272,14 +272,6 @@
272
272
  "win32-arm64",
273
273
  "win32-x64"
274
274
  ],
275
- "bare-tty/node_modules/bare-signals": [
276
- "darwin-arm64",
277
- "darwin-x64",
278
- "linux-arm64",
279
- "linux-x64",
280
- "win32-arm64",
281
- "win32-x64"
282
- ],
283
275
  "bare-tty": [
284
276
  "darwin-arm64",
285
277
  "darwin-x64",
@@ -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;
@@ -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';
@@ -202,7 +202,7 @@ const TOOL_DENIED_MESSAGE = 'The user declined this call. Do not retry it or cal
202
202
  function deniedResult() {
203
203
  return { error: TOOL_DENIED_MESSAGE, denied: true };
204
204
  }
205
- function denialKey(name, args) {
205
+ function callKey(name, args) {
206
206
  try {
207
207
  return `${name}:${JSON.stringify(args)}`;
208
208
  }
@@ -210,6 +210,34 @@ function denialKey(name, args) {
210
210
  return name;
211
211
  }
212
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;
213
241
  function terminalCall(result, outcome, options) {
214
242
  return { result, outcome, executed: options.executed };
215
243
  }
@@ -250,7 +278,7 @@ async function executeCall(tool, args, ctx, { approve, signal, denied, sources =
250
278
  });
251
279
  return blockedResult({ error: refusal, guidance: true }, refusal);
252
280
  }
253
- const key = denialKey(tool.name, args);
281
+ const key = callKey(tool.name, args);
254
282
  if (denied?.has(key)) {
255
283
  hlog('tool denied already, not asking again', { tool: tool.name });
256
284
  return deniedCall();
@@ -366,7 +394,7 @@ function blockedSkillMessage(name, catalog, listShown) {
366
394
  `skill is available this turn — do not guess another name, and answer from what you ` +
367
395
  `already have.`;
368
396
  }
369
- async function resolveToolCalls(toolCalls, enabled, agentId, pipeline, skillNames, signal) {
397
+ async function resolveToolCalls(toolCalls, enabled, agentId, pipeline, skillNames, { sandboxMode, signal }) {
370
398
  const resolved = [];
371
399
  for (const call of toolCalls) {
372
400
  if (signal?.aborted)
@@ -390,6 +418,7 @@ async function resolveToolCalls(toolCalls, enabled, agentId, pipeline, skillName
390
418
  agentId,
391
419
  skillNames,
392
420
  parameters: tool.parameters,
421
+ sandboxMode,
393
422
  signal
394
423
  });
395
424
  resolved.push(fold.ok
@@ -611,7 +640,10 @@ function* reportUnnamedWrites(said, landed) {
611
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 }) {
612
641
  const pipeline = new HookPipeline();
613
642
  const agentId = agent?.id ?? null;
614
- 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);
615
647
  const writableRoots = [
616
648
  ...fsWriteRoots,
617
649
  ...fsRoots.filter((root) => root.kind === 'hyper' && root.writable && !fsWriteRoots.includes(root))
@@ -766,6 +798,8 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
766
798
  let roundCacheKey = stubDropped ? undefined : visionHistoryCacheKey(cacheKey, history);
767
799
  const runUsage = () => usageBreakdown(promptParts, stats.cacheTokens, model);
768
800
  const denied = new Set();
801
+ const failed = new Map();
802
+ let blockedRepeats = 0;
769
803
  let webSearches = 0;
770
804
  const sources = trustedSourcesFrom(messages);
771
805
  if (preloadedSkill)
@@ -943,7 +977,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
943
977
  }
944
978
  mute = 0;
945
979
  narrated = false;
946
- 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 });
947
981
  if (signal?.aborted)
948
982
  throw new AbortedError();
949
983
  history = [
@@ -954,10 +988,12 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
954
988
  }
955
989
  ];
956
990
  let cappedCalls = 0;
991
+ let progressed = false;
957
992
  runOutcomes.beginRound();
958
993
  for (const { call, tool, args, rawCall, rejection, intent } of resolved) {
959
994
  if (signal?.aborted)
960
995
  throw new AbortedError();
996
+ const priorFailure = failed.get(callKey(call.name, args));
961
997
  const searchCapped = tool?.name === WEB_SEARCH_TOOL_NAME && webSearches >= MAX_WEB_SEARCHES_PER_TURN;
962
998
  const callCtx = {
963
999
  ...toolContext(turnImages, ctxSize),
@@ -975,7 +1011,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
975
1011
  const resource = tool?.resource?.(args) ?? null;
976
1012
  const skillName = unquoteArg(args?.name ?? args?.skill_name ?? args?.skill);
977
1013
  const skillReloaded = tool?.name === SKILL_TOOL_NAME && !args?.file && loadedThisTurn.has(skillName);
978
- const silent = searchCapped || skillReloaded;
1014
+ const silent = searchCapped || skillReloaded || priorFailure !== undefined;
979
1015
  const display = silent
980
1016
  ? undefined
981
1017
  : labelOf(call.name, args, skillCall ? skillEmoji(skillName) : skillIcon);
@@ -989,7 +1025,15 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
989
1025
  const unreadyReason = `skill "${skillName}" is ${unreadyState ?? 'unavailable'} and cannot be used yet. ` +
990
1026
  `Tell the user to open it in Skills and finish its setup; do not retry.`;
991
1027
  let execution;
992
- 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) {
993
1037
  execution = terminalCall({
994
1038
  name: skillName,
995
1039
  content: `${skillName} is already loaded. Follow the instructions you were given and do not load it again.`
@@ -1037,13 +1081,20 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1037
1081
  });
1038
1082
  const output = settled.result;
1039
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
+ }
1040
1090
  runOutcomes.record(output, executionOutcome);
1041
1091
  if (searchCapped) {
1042
1092
  cappedCalls++;
1043
1093
  hlog('web_search capped', { searches: webSearches });
1044
1094
  }
1045
- else if (tool?.name === WEB_SEARCH_TOOL_NAME && !rejection)
1095
+ else if (tool?.name === WEB_SEARCH_TOOL_NAME && !rejection && !priorFailure) {
1046
1096
  webSearches++;
1097
+ }
1047
1098
  if (call.name === SKILL_TOOL_NAME &&
1048
1099
  output &&
1049
1100
  typeof output === 'object' &&
@@ -1095,7 +1146,13 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1095
1146
  history.push({ role: 'tool', content: turn });
1096
1147
  }
1097
1148
  runOutcomes.finishRound();
1149
+ if (progressed) {
1150
+ failed.clear();
1151
+ blockedRepeats = 0;
1152
+ }
1098
1153
  history.push({ role: 'user', content: TOOL_CONTINUATION_NUDGE });
1154
+ if (blockedRepeats >= MAX_BLOCKED_REPEATS)
1155
+ break;
1099
1156
  if (cappedCalls === resolved.length) {
1100
1157
  if (cappedRounds++ >= MAX_CAPPED_ROUNDS) {
1101
1158
  outcome = 'search-capped';
@@ -656,7 +656,8 @@ function knobs(agent) {
656
656
  imageWidth: agent.imageWidth ?? undefined,
657
657
  imageHeight: agent.imageHeight ?? undefined,
658
658
  approvalMode: agent.approvalMode ?? undefined,
659
- disabledToolsets: agent.disabledToolsets ?? undefined
659
+ disabledToolsets: agent.disabledToolsets ?? undefined,
660
+ sandboxMode: agent.sandboxMode ?? undefined
660
661
  };
661
662
  }
662
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;
@@ -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>;
@@ -0,0 +1,38 @@
1
+ import path from 'path';
2
+ import { extractDocxText } from '../docx/index.js';
3
+ import { extractWithPython } from './python.js';
4
+ import { XLSX_EXTRACT } from './xlsx.js';
5
+ import { PPTX_EXTRACT } from './pptx.js';
6
+ export { closePythonWorker as closeOfficeWorker } from '../tools/exec/adapters/python/worker/client.js';
7
+ const OFFICE_MIME_EXTENSIONS = {
8
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
9
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
10
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx'
11
+ };
12
+ export async function extractOfficeText(bytes, fileName, options = {}) {
13
+ const mimeType = options.mimeType?.split(';')[0]?.trim().toLowerCase();
14
+ const suffix = path.extname(fileName).toLowerCase();
15
+ const extension = ['.docx', '.xlsx', '.pptx'].includes(suffix)
16
+ ? suffix
17
+ : OFFICE_MIME_EXTENSIONS[mimeType ?? ''];
18
+ if (extension === '.docx') {
19
+ return extractDocxText(bytes, fileName, options);
20
+ }
21
+ if (extension === '.xlsx') {
22
+ return extractWithPython(bytes, fileName, {
23
+ format: 'XLSX',
24
+ packages: ['openpyxl==3.1.5'],
25
+ source: XLSX_EXTRACT,
26
+ signal: options.signal
27
+ });
28
+ }
29
+ if (extension === '.pptx') {
30
+ return extractWithPython(bytes, fileName, {
31
+ format: 'PPTX',
32
+ packages: ['python-pptx==1.0.2'],
33
+ source: PPTX_EXTRACT,
34
+ signal: options.signal
35
+ });
36
+ }
37
+ throw new Error(`Unsupported Office document: ${fileName}`);
38
+ }
@@ -0,0 +1 @@
1
+ export declare const PPTX_EXTRACT: string;
@@ -0,0 +1,26 @@
1
+ export const PPTX_EXTRACT = `from pptx import Presentation
2
+ from pptx.enum.shapes import MSO_SHAPE_TYPE
3
+ from pathlib import Path
4
+
5
+ def text(shapes):
6
+ for shape in shapes:
7
+ if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
8
+ yield from text(shape.shapes)
9
+ elif shape.has_text_frame:
10
+ if shape.text.strip():
11
+ yield shape.text
12
+ elif shape.has_table:
13
+ for row in shape.table.rows:
14
+ cells = ["" if cell.is_spanned else cell.text.replace("\\n", " ") for cell in row.cells]
15
+ if any(cell.strip() for cell in cells):
16
+ yield " | ".join(cells)
17
+
18
+ with Path("extracted.txt").open("w", encoding="utf-8") as output:
19
+ for number, slide in enumerate(Presentation("input.pptx").slides, 1):
20
+ heading = False
21
+ for block in text(slide.shapes):
22
+ if not heading:
23
+ output.write("Slide " + str(number) + "\\n")
24
+ heading = True
25
+ output.write(block + "\\n")
26
+ `;
@@ -0,0 +1,7 @@
1
+ import type { AbortSignal } from 'bare-abort-controller';
2
+ export declare function extractWithPython(bytes: Uint8Array, fileName: string, { format, packages, source, signal }: {
3
+ readonly format: string;
4
+ readonly packages: readonly string[];
5
+ readonly source: string;
6
+ readonly signal?: AbortSignal | undefined;
7
+ }): Promise<string>;
@@ -0,0 +1,32 @@
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';
5
+ const MAX_OFFICE_BYTES = 20 * 1024 * 1024;
6
+ export async function extractWithPython(bytes, fileName, { format, packages, source, signal }) {
7
+ if (bytes.byteLength > MAX_OFFICE_BYTES) {
8
+ throw new Error(`document is too large to extract: ${bytes.byteLength} bytes, limit ${MAX_OFFICE_BYTES}`);
9
+ }
10
+ const dir = wheelsDir();
11
+ const manifest = vendoredManifest(dir);
12
+ const install = planVendoredInstall(packages, { dir, manifest });
13
+ if (!dir ||
14
+ !manifest ||
15
+ !install ||
16
+ !['micropip', ...install.packages].every((name) => Object.keys(manifest.files).some((file) => file.replaceAll('_', '-').startsWith(`${name}-`) && fs.existsSync(`${dir}/${file}`)))) {
17
+ throw new Error(`bundled ${format} extraction packages are unavailable`);
18
+ }
19
+ const result = await runPython(source, {
20
+ packages,
21
+ inputs: [{ path: `input.${format.toLowerCase()}`, bytes }],
22
+ outputs: ['extracted.txt'],
23
+ signal
24
+ });
25
+ if (result.exitCode !== 0)
26
+ throw new Error(`${fileName}: ${result.stderr.trim()}`);
27
+ const output = result.files?.get('extracted.txt');
28
+ if (!output) {
29
+ throw new Error(result.rejected?.[0]?.reason ?? `${format} extraction produced no text file`);
30
+ }
31
+ return b4a.toString(output, 'utf8');
32
+ }
@@ -0,0 +1 @@
1
+ export declare const XLSX_EXTRACT: string;
@@ -0,0 +1,26 @@
1
+ export const XLSX_EXTRACT = `from openpyxl import load_workbook
2
+ from pathlib import Path
3
+
4
+ formulas = load_workbook("input.xlsx", read_only=True, data_only=False, keep_links=False)
5
+ values = load_workbook("input.xlsx", read_only=True, data_only=True, keep_links=False)
6
+ try:
7
+ with Path("extracted.txt").open("w", encoding="utf-8") as output:
8
+ for sheet in formulas.worksheets:
9
+ heading = False
10
+ cached = values[sheet.title]
11
+ sheet.reset_dimensions()
12
+ cached.reset_dimensions()
13
+ for cells, results in zip(sheet.iter_rows(), cached.iter_rows()):
14
+ row = [result.value if cell.data_type == "f" and result.value is not None else cell.value
15
+ for cell, result in zip(cells, results)]
16
+ if not any(value is not None and str(value).strip() for value in row):
17
+ continue
18
+ if not heading:
19
+ output.write("Sheet: " + sheet.title + "\\n")
20
+ heading = True
21
+ output.write(" | ".join("" if value is None else str(value).replace("\\n", " ")
22
+ for value in row) + "\\n")
23
+ finally:
24
+ formulas.close()
25
+ values.close()
26
+ `;
@@ -170,7 +170,7 @@ export function createEditImageTool(runtime, { loadMedia = defaultLoadMedia, res
170
170
  if (sources.length > 1 && !inContext)
171
171
  return { error: MULTI_SOURCE_UNSUPPORTED };
172
172
  if (!runtime.acquire())
173
- return { error: BUSY_ERROR };
173
+ return { error: BUSY_ERROR, code: 'busy' };
174
174
  const report = ctx.progress;
175
175
  const onTick = report && generationPercentTick((percentage) => report({ percentage }), sources.length);
176
176
  try {
@@ -323,7 +323,7 @@ export function createGenerateImageTool(runtime, clamps) {
323
323
  if (!ctx?.attach)
324
324
  return { error: 'this device cannot persist generated images' };
325
325
  if (!runtime.acquire())
326
- return { error: BUSY_ERROR };
326
+ return { error: BUSY_ERROR, code: 'busy' };
327
327
  const report = ctx.progress;
328
328
  const onTick = report && generationPercentTick((percentage) => report({ percentage }));
329
329
  try {
@@ -443,7 +443,7 @@ export function createGenerateMusicTool(runtime, clamps) {
443
443
  if (!ctx?.attach)
444
444
  return { error: 'this device cannot persist generated audio' };
445
445
  if (!runtime.acquire())
446
- return { error: BUSY_ERROR };
446
+ return { error: BUSY_ERROR, code: 'busy' };
447
447
  const report = ctx.progress;
448
448
  const onTick = report && musicPercentTick((percentage) => report({ percentage }));
449
449
  const capMax = stepsCap ?? MAX_STEPS;
@@ -163,10 +163,10 @@ export function createHttpRequestTool({ fetch: fetchImpl, credentialStore, crede
163
163
  }
164
164
  catch (err) {
165
165
  const error = err instanceof Error ? err : new Error(String(err));
166
+ const timedOut = error.name === 'AbortError' || error instanceof AbortedError;
166
167
  return {
167
- error: error.name === 'AbortError' || error instanceof AbortedError
168
- ? 'request timed out'
169
- : `request failed: ${error.message}`
168
+ error: timedOut ? 'request timed out' : `request failed: ${error.message}`,
169
+ code: timedOut ? 'timeout' : 'transport'
170
170
  };
171
171
  }
172
172
  finally {
@@ -141,10 +141,10 @@ export function createMcpCallTool({ fetch: fetchImpl, credentialStore, credentia
141
141
  }
142
142
  catch (err) {
143
143
  const error = err instanceof Error ? err : new Error(String(err));
144
+ const timedOut = error.name === 'AbortError' || error instanceof AbortedError;
144
145
  return {
145
- error: error.name === 'AbortError' || error instanceof AbortedError
146
- ? 'request timed out'
147
- : `request failed: ${error.message}`
146
+ error: timedOut ? 'request timed out' : `request failed: ${error.message}`,
147
+ code: timedOut ? 'timeout' : 'transport'
148
148
  };
149
149
  }
150
150
  finally {
@@ -131,8 +131,10 @@ export function createSkillTool({ skillsDir, overlayDir = resolveSkillsOverlayDi
131
131
  if (file === MAIN_FILE)
132
132
  return { error: `skill not found: ${name}` };
133
133
  const available = await listSkillFiles([overlayDrive, baseDrive], name);
134
- const listing = available.length ? ` — files this skill has: ${available.join(', ')}` : '';
135
- return { error: `file not found in skill ${name}: ${file}${listing}` };
134
+ const rest = available.length
135
+ ? ` — files this skill has: ${available.join(', ')}`
136
+ : '. This skill has no other files; you already have its full instructions — act now.';
137
+ return { error: `file not found in skill ${name}: ${file}${rest}` };
136
138
  }
137
139
  }
138
140
  };
@@ -19,6 +19,7 @@ export declare function isCredentialUrlAllowed(target: string, patterns: readonl
19
19
  export type CredentialAllowListFn = (ctx?: ToolExecuteContext) => CredentialAllowList;
20
20
  export type AuthOutcome = {
21
21
  readonly token: string;
22
+ readonly key: string;
22
23
  } | {
23
24
  readonly error: string;
24
25
  };