@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.
- package/README.md +15 -7
- package/dist/harness.bundle +7 -59
- package/dist/prebuilds-manifest.json +0 -8
- package/dist-lib/lib/capabilities/completion.d.ts +1 -0
- package/dist-lib/lib/capabilities/completion.js +66 -9
- package/dist-lib/lib/connect.js +2 -1
- package/dist-lib/lib/contract-types.d.ts +1 -0
- package/dist-lib/lib/docx/index.js +4 -27
- package/dist-lib/lib/hooks/built-in/index.d.ts +1 -0
- package/dist-lib/lib/hooks/built-in/index.js +1 -0
- package/dist-lib/lib/hooks/built-in/sandbox.d.ts +5 -0
- package/dist-lib/lib/hooks/built-in/sandbox.js +43 -0
- package/dist-lib/lib/hooks/built-in/skill-catalog.js +7 -2
- package/dist-lib/lib/hooks/registry.js +2 -1
- package/dist-lib/lib/hooks/types.d.ts +1 -0
- package/dist-lib/lib/office/index.d.ts +6 -0
- package/dist-lib/lib/office/index.js +38 -0
- package/dist-lib/lib/office/pptx.d.ts +1 -0
- package/dist-lib/lib/office/pptx.js +26 -0
- package/dist-lib/lib/office/python.d.ts +7 -0
- package/dist-lib/lib/office/python.js +32 -0
- package/dist-lib/lib/office/xlsx.d.ts +1 -0
- package/dist-lib/lib/office/xlsx.js +26 -0
- package/dist-lib/lib/tools/edit-image.js +1 -1
- package/dist-lib/lib/tools/generate-image.js +1 -1
- package/dist-lib/lib/tools/generate-music.js +1 -1
- package/dist-lib/lib/tools/http-request.js +3 -3
- package/dist-lib/lib/tools/mcp-call.js +3 -3
- package/dist-lib/lib/tools/skill.js +4 -2
- package/dist-lib/lib/tools/skills/auth-resolver.d.ts +1 -0
- package/dist-lib/lib/tools/skills/auth-resolver.js +59 -29
- package/dist-lib/lib/tools/toolsets.d.ts +1 -0
- package/dist-lib/lib/tools/toolsets.js +1 -0
- package/dist-lib/lib/tools/weather-lookup.js +9 -3
- package/dist-lib/lib/tools/web-fetch.js +6 -3
- package/dist-lib/lib/tools/web-search.js +5 -2
- package/dist-lib/schema/harness.js +2 -1
- package/dist-lib/spec/hrpc/messages.js +8 -5
- package/dist-lib/spec/hyperschema/index.js +8 -5
- package/dist-lib/spec/hyperschema/schema.json +6 -1
- package/dist-lib/spec/hyperschema/types.d.ts +2 -1
- 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",
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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 (
|
|
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';
|
package/dist-lib/lib/connect.js
CHANGED
|
@@ -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) {
|
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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 (
|
|
15
|
-
return
|
|
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:
|
|
168
|
-
|
|
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:
|
|
146
|
-
|
|
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
|
|
135
|
-
|
|
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
|
};
|