@qvac/harness 0.2.9 → 0.2.12

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 (57) hide show
  1. package/README.md +15 -7
  2. package/dist/harness.bundle +11 -62
  3. package/dist/prebuilds-manifest.json +0 -8
  4. package/dist-lib/index.d.ts +16 -1
  5. package/dist-lib/index.js +31 -1
  6. package/dist-lib/lib/capabilities/completion.d.ts +1 -0
  7. package/dist-lib/lib/capabilities/completion.js +66 -9
  8. package/dist-lib/lib/capabilities/detect-language.d.ts +11 -0
  9. package/dist-lib/lib/capabilities/detect-language.js +13 -0
  10. package/dist-lib/lib/capabilities/invoke.d.ts +15 -0
  11. package/dist-lib/lib/capabilities/invoke.js +33 -0
  12. package/dist-lib/lib/connect.d.ts +4 -1
  13. package/dist-lib/lib/connect.js +51 -22
  14. package/dist-lib/lib/contract-types.d.ts +21 -0
  15. package/dist-lib/lib/docx/index.js +4 -27
  16. package/dist-lib/lib/hooks/built-in/index.d.ts +1 -0
  17. package/dist-lib/lib/hooks/built-in/index.js +1 -0
  18. package/dist-lib/lib/hooks/built-in/sandbox.d.ts +5 -0
  19. package/dist-lib/lib/hooks/built-in/sandbox.js +45 -0
  20. package/dist-lib/lib/hooks/built-in/skill-catalog.js +7 -2
  21. package/dist-lib/lib/hooks/registry.js +2 -1
  22. package/dist-lib/lib/hooks/types.d.ts +1 -0
  23. package/dist-lib/lib/office/index.d.ts +6 -0
  24. package/dist-lib/lib/office/index.js +38 -0
  25. package/dist-lib/lib/office/pptx.d.ts +1 -0
  26. package/dist-lib/lib/office/pptx.js +26 -0
  27. package/dist-lib/lib/office/python.d.ts +7 -0
  28. package/dist-lib/lib/office/python.js +32 -0
  29. package/dist-lib/lib/office/xlsx.d.ts +1 -0
  30. package/dist-lib/lib/office/xlsx.js +26 -0
  31. package/dist-lib/lib/serve.js +32 -4
  32. package/dist-lib/lib/tools/edit-image.js +1 -1
  33. package/dist-lib/lib/tools/generate-image.js +1 -1
  34. package/dist-lib/lib/tools/generate-music.js +1 -1
  35. package/dist-lib/lib/tools/http-request.js +4 -4
  36. package/dist-lib/lib/tools/mcp-call.js +3 -3
  37. package/dist-lib/lib/tools/skill.js +4 -2
  38. package/dist-lib/lib/tools/skills/auth-resolver.d.ts +1 -0
  39. package/dist-lib/lib/tools/skills/auth-resolver.js +59 -29
  40. package/dist-lib/lib/tools/skills/manifest-parse.d.ts +1 -0
  41. package/dist-lib/lib/tools/skills/manifest-parse.js +92 -1
  42. package/dist-lib/lib/tools/skills/manifest.d.ts +12 -0
  43. package/dist-lib/lib/tools/toolsets.d.ts +1 -0
  44. package/dist-lib/lib/tools/toolsets.js +1 -0
  45. package/dist-lib/lib/tools/weather-lookup.js +9 -3
  46. package/dist-lib/lib/tools/web-fetch.js +18 -5
  47. package/dist-lib/lib/tools/web-search.js +5 -2
  48. package/dist-lib/schema/harness.js +54 -4
  49. package/dist-lib/spec/bind.js +3 -0
  50. package/dist-lib/spec/calls.js +1 -0
  51. package/dist-lib/spec/hrpc/hrpc.json +13 -0
  52. package/dist-lib/spec/hrpc/index.js +15 -3
  53. package/dist-lib/spec/hrpc/messages.js +207 -13
  54. package/dist-lib/spec/hyperschema/index.js +207 -13
  55. package/dist-lib/spec/hyperschema/schema.json +163 -1
  56. package/dist-lib/spec/hyperschema/types.d.ts +43 -2
  57. package/package.json +7 -2
@@ -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",
@@ -1,6 +1,8 @@
1
1
  import type { AbortSignal as BareAbortSignal } from 'bare-abort-controller';
2
2
  import ReadyResource from 'ready-resource';
3
3
  import { type Agent, type ApproveFn, type AskUserFn, type AttachFn, type CompletionEvent, type FetchAttachmentFn, type Message, type ProgressFn, type RagSearchFn, type ResponseFormat, type ToolDefinition, type ToolExecuteContext } from './lib/capabilities/completion.ts';
4
+ import { type DetectLanguageContext, type DetectedLanguage } from './lib/capabilities/detect-language.ts';
5
+ import { type Capability } from './lib/capabilities/invoke.ts';
4
6
  import { type DownloadArgs, type DownloadEventStream } from './lib/capabilities/download.ts';
5
7
  import { type EmbedContext } from './lib/capabilities/embed.ts';
6
8
  import { type KvCacheDeletionResult } from './lib/capabilities/kv-cache.ts';
@@ -22,6 +24,8 @@ import type { ResolvedSkillCatalogEntry } from './lib/tools/skills/catalog.ts';
22
24
  import { type SkillSettingEntry } from './lib/tools/skills/skill-settings.ts';
23
25
  export * from './lib/capabilities/completion.ts';
24
26
  export { attachmentTurn, loadedSkillsFrom, skillLoadedTurn } from './lib/capabilities/tool-turns.ts';
27
+ export { CAPABILITIES, isCapability, type Capability } from './lib/capabilities/invoke.ts';
28
+ export type { DetectLanguageContext, DetectedLanguage } from './lib/capabilities/detect-language.ts';
25
29
  export * from './lib/capabilities/download.ts';
26
30
  export type { EmbedContext } from './lib/capabilities/embed.ts';
27
31
  export type { SpeakContext, SpeakResult, SpeakStreamContext, TranscribeContext } from './lib/capabilities/voice.ts';
@@ -37,7 +41,7 @@ export { requiredSettings, settingEnvName, type SkillSettingEntry, type SkillSet
37
41
  export type { ToolCatalogEntry } from './lib/tools/catalog.ts';
38
42
  export { TOOLSETS, toolsetEnabled, type Toolset } from './lib/tools/toolsets.ts';
39
43
  export { markCredentialRejected, markCredentialValid, recordCredentialHealth, resetAllCredentialHealth, resetCredentialHealth } from './lib/tools/skills/credential-health.ts';
40
- export type { InstallStep, SkillRequires, SkillSetup, SkillSetupField, SkillSetupRoute } from './lib/tools/skills/manifest.ts';
44
+ export type { InstallStep, SkillOAuthConfig, SkillRequires, SkillSetup, SkillSetupField, SkillSetupRoute } from './lib/tools/skills/manifest.ts';
41
45
  export { readSetup as normalizeSkillSetup } from './lib/tools/skills/manifest-parse.ts';
42
46
  export type { CredentialRecord, CredentialStoreValue } from './lib/tools/skills/auth-resolver.ts';
43
47
  export { isAvailableState, SKILL_STATE, type SkillState } from './lib/tools/skills/skill-state.ts';
@@ -106,6 +110,15 @@ export interface CompletionContext {
106
110
  askUser?: AskUserFn;
107
111
  signal?: BareAbortSignal;
108
112
  }
113
+ export interface InvokeContext {
114
+ capability: Capability;
115
+ args: Record<string, unknown>;
116
+ agent?: Agent;
117
+ attach?: AttachFn;
118
+ fetchAttachment?: FetchAttachmentFn;
119
+ progress?: ProgressFn;
120
+ signal?: BareAbortSignal;
121
+ }
109
122
  export declare class Harness extends ReadyResource {
110
123
  private _tools;
111
124
  private _sdk;
@@ -224,6 +237,7 @@ export declare class Harness extends ReadyResource {
224
237
  private _defaultVoiceSrc;
225
238
  private _voiceJob;
226
239
  transcribe({ model, audio, signal }: TranscribeContext): Promise<string>;
240
+ detectLanguage(context: DetectLanguageContext): DetectedLanguage[];
227
241
  speak({ model, text, signal }: SpeakContext): Promise<SpeakResult>;
228
242
  speakStream({ model, text, signal }: SpeakStreamContext): AsyncIterable<SpeakResult>;
229
243
  list(options?: ListModelsOptions): Promise<ModelInfo[]>;
@@ -240,6 +254,7 @@ export declare class Harness extends ReadyResource {
240
254
  agent?: Agent;
241
255
  signal?: BareAbortSignal;
242
256
  }): Promise<void>;
257
+ invoke({ capability, args, agent, attach, fetchAttachment, progress, signal }: InvokeContext): AsyncGenerator<CompletionEvent>;
243
258
  completion({ model, agent, messages, cacheKey, thinkingDefault, toolConfigs, generationParams, responseFormat, captureInferenceCalls, grantedDirs, grantedDrives, driveOp, grantedWriteDirs, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal }: CompletionContext): AsyncGenerator<CompletionEvent>;
244
259
  private _makeRoomForCompletion;
245
260
  private _evictWedgedModel;
package/dist-lib/index.js CHANGED
@@ -5,7 +5,9 @@ import ReadyResource from 'ready-resource';
5
5
  import { probeGpuStats } from './lib/hardware/gpu-probe.js';
6
6
  import { probeCapabilityProfile } from './lib/hardware/capability-probe.js';
7
7
  import { closePythonWorker } from './lib/tools/exec/adapters/python/worker/client.js';
8
- import { completion } from './lib/capabilities/completion.js';
8
+ import { completion, imageSettingsFrom } from './lib/capabilities/completion.js';
9
+ import { detectLanguage } from './lib/capabilities/detect-language.js';
10
+ import { invoke } from './lib/capabilities/invoke.js';
9
11
  import { download } from './lib/capabilities/download.js';
10
12
  import { createEmbedLane } from './lib/capabilities/embed.js';
11
13
  import { deleteAllKvCaches, deleteKvCacheKey } from './lib/capabilities/kv-cache.js';
@@ -45,6 +47,7 @@ import { isDeviceSharedCredentialKey, scopeKey } from './lib/tools/skills/scope-
45
47
  import { settingsScopeKey } from './lib/tools/skills/skill-settings.js';
46
48
  export * from './lib/capabilities/completion.js';
47
49
  export { attachmentTurn, loadedSkillsFrom, skillLoadedTurn } from './lib/capabilities/tool-turns.js';
50
+ export { CAPABILITIES, isCapability } from './lib/capabilities/invoke.js';
48
51
  export * from './lib/capabilities/download.js';
49
52
  export * from './lib/models/model-catalog.js';
50
53
  export * from './lib/models/model-list.js';
@@ -809,6 +812,9 @@ export class Harness extends ReadyResource {
809
812
  return transcribeOnce(sdk, id, pcm ? buildWavBuffer(pcm) : audio);
810
813
  });
811
814
  }
815
+ detectLanguage(context) {
816
+ return detectLanguage(context);
817
+ }
812
818
  async speak({ model, text, signal }) {
813
819
  const samples = await this._voiceJob(this._ttsModels, 'tts', model, signal, (sdk, id) => speakOnce(sdk, id, text));
814
820
  return { audio: packSamples(samples), sampleRate: SUPERTONIC_SAMPLE_RATE };
@@ -943,6 +949,30 @@ export class Harness extends ReadyResource {
943
949
  releaseModel(pool, key);
944
950
  }
945
951
  }
952
+ async *invoke({ capability, args, agent, attach, fetchAttachment, progress, signal }) {
953
+ if (!this.opened)
954
+ await this.ready();
955
+ if (this.suspended)
956
+ return yield { type: 'aborted' };
957
+ hlog('invoke start', { capability });
958
+ try {
959
+ yield* invoke({
960
+ capability,
961
+ args,
962
+ tools: this._tools,
963
+ ctx: {
964
+ image: imageSettingsFrom(agent),
965
+ attach,
966
+ fetchAttachment,
967
+ progress,
968
+ signal
969
+ }
970
+ });
971
+ }
972
+ finally {
973
+ hlog('invoke end', { capability });
974
+ }
975
+ }
946
976
  async *completion({ model, agent, messages, cacheKey, thinkingDefault, toolConfigs, generationParams, responseFormat, captureInferenceCalls, grantedDirs, grantedDrives, driveOp, grantedWriteDirs, approve, attach, fetchAttachment, ragSearch, progress, askUser, signal }) {
947
977
  await this.ready();
948
978
  if (this.suspended)
@@ -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';
@@ -0,0 +1,11 @@
1
+ export interface DetectedLanguage {
2
+ code: string;
3
+ language: string;
4
+ probability?: number;
5
+ }
6
+ export interface DetectLanguageContext {
7
+ text: string;
8
+ topK?: number;
9
+ }
10
+ export declare const UNDETERMINED = "und";
11
+ export declare function detectLanguage({ text, topK }: DetectLanguageContext): DetectedLanguage[];
@@ -0,0 +1,13 @@
1
+ import { detectMultiple, detectOne } from '@qvac/langdetect-text';
2
+ export const UNDETERMINED = 'und';
3
+ export function detectLanguage({ text, topK }) {
4
+ if (!topK || topK === 1) {
5
+ const { code, language } = detectOne(text);
6
+ return [{ code, language }];
7
+ }
8
+ return detectMultiple(text, topK).map(({ code, language, probability }) => ({
9
+ code,
10
+ language,
11
+ probability
12
+ }));
13
+ }
@@ -0,0 +1,15 @@
1
+ import type { CompletionEvent, ToolDefinition, ToolExecuteContext } from './completion.ts';
2
+ declare const CAPABILITY_TOOLS: {
3
+ readonly image: 'generate_image';
4
+ };
5
+ export type Capability = keyof typeof CAPABILITY_TOOLS;
6
+ export declare const CAPABILITIES: Capability[];
7
+ export declare function isCapability(name: string): name is Capability;
8
+ export interface InvokeOptions {
9
+ capability: Capability;
10
+ args: Record<string, unknown>;
11
+ tools: readonly ToolDefinition[];
12
+ ctx: ToolExecuteContext;
13
+ }
14
+ export declare function invoke({ capability, args, tools, ctx }: InvokeOptions): AsyncGenerator<CompletionEvent>;
15
+ export {};
@@ -0,0 +1,33 @@
1
+ import { AbortedError } from '../helpers/race-abort.js';
2
+ const CAPABILITY_TOOLS = {
3
+ image: 'generate_image'
4
+ };
5
+ export const CAPABILITIES = Object.keys(CAPABILITY_TOOLS);
6
+ export function isCapability(name) {
7
+ return name in CAPABILITY_TOOLS;
8
+ }
9
+ export async function* invoke({ capability, args, tools, ctx }) {
10
+ const name = CAPABILITY_TOOLS[capability];
11
+ const tool = tools.find((candidate) => candidate.name === name);
12
+ if (!tool) {
13
+ yield { type: 'error', message: `${capability} is not available on this device` };
14
+ return;
15
+ }
16
+ const startedAt = Date.now();
17
+ yield { type: 'tool-call', name, args, effect: tool.effect(args, ctx) };
18
+ try {
19
+ const result = await tool.execute(args, ctx);
20
+ if (ctx.signal?.aborted)
21
+ return yield { type: 'aborted' };
22
+ yield { type: 'tool-result', name, result };
23
+ }
24
+ catch (err) {
25
+ if (err instanceof AbortedError || ctx.signal?.aborted) {
26
+ yield { type: 'aborted' };
27
+ return;
28
+ }
29
+ yield { type: 'error', message: err instanceof Error ? err.message : String(err) };
30
+ return;
31
+ }
32
+ yield { type: 'metrics', totalTokens: 0, durationMs: Date.now() - startedAt };
33
+ }
@@ -1,7 +1,7 @@
1
1
  import EventEmitter from 'events';
2
2
  import { type Transport } from './rpc-session.ts';
3
3
  import { type KvCacheDeletionResult } from './capabilities/kv-cache.ts';
4
- import type { CapabilityInfo, CompletionContext, DownloadContext, EmbedContext, HarnessContract, HarnessDiagnostics, HarnessEvent, HarnessInfo, ListModelsOptions, ModelInfo, ResolvedSkillCatalogEntry, SpeakContext, SpeakResult, SpeakStreamContext, ToolCatalogEntry, TranscribeContext, WarmModelContext } from './contract-types.ts';
4
+ import type { CapabilityInfo, CompletionContext, DetectedLanguage, DetectLanguageContext, DownloadContext, EmbedContext, HarnessContract, HarnessDiagnostics, HarnessEvent, HarnessInfo, InvokeContext, ListModelsOptions, ModelInfo, ResolvedSkillCatalogEntry, SpeakContext, SpeakResult, SpeakStreamContext, ToolCatalogEntry, TranscribeContext, WarmModelContext } from './contract-types.ts';
5
5
  import { type EmbedDevice } from './models/embed-config.ts';
6
6
  import type { DeleteModelSummary } from './models/model-delete.ts';
7
7
  import type { ManagedSkill, SkillFileContent } from './tools/skills/overlay-crud.ts';
@@ -20,9 +20,12 @@ export declare class RemoteHarness extends EventEmitter implements HarnessContra
20
20
  private _ensure;
21
21
  private _open;
22
22
  completion(context: CompletionContext): AsyncIterable<HarnessEvent>;
23
+ invoke(context: InvokeContext): AsyncIterable<HarnessEvent>;
24
+ private _run;
23
25
  download(context: DownloadContext): AsyncIterable<HarnessEvent>;
24
26
  embed(context: EmbedContext): Promise<number[][]>;
25
27
  transcribe(context: TranscribeContext): Promise<string>;
28
+ detectLanguage(context: DetectLanguageContext): Promise<DetectedLanguage[]>;
26
29
  speak(context: SpeakContext): Promise<SpeakResult>;
27
30
  speakStream(context: SpeakStreamContext): AsyncIterable<SpeakResult>;
28
31
  getEmbeddingDevice(): Promise<EmbedDevice>;
@@ -91,6 +91,44 @@ export class RemoteHarness extends EventEmitter {
91
91
  return session;
92
92
  }
93
93
  async *completion(context) {
94
+ yield* this._run({
95
+ type: 'start',
96
+ model: context.model,
97
+ agent: knobs(context.agent),
98
+ cacheKey: context.cacheKey,
99
+ thinkingDefault: context.thinkingDefault,
100
+ generationParams: context.generationParams,
101
+ responseFormat: context.responseFormat,
102
+ captureInferenceCalls: context.captureInferenceCalls,
103
+ messages: context.messages.map(({ role, content, attachments }) => ({
104
+ role,
105
+ content,
106
+ attachments: attachments?.map(({ path: file }) => ({ path: file }))
107
+ })),
108
+ toolConfigs: context.toolConfigs,
109
+ grantedDirs: context.grantedDirs,
110
+ grantedDrives: context.grantedDrives,
111
+ grantedWriteDirs: context.grantedWriteDirs
112
+ }, context);
113
+ }
114
+ async *invoke(context) {
115
+ const agent = context.agent ?? {};
116
+ yield* this._run({
117
+ type: 'invoke',
118
+ capability: context.capability,
119
+ arguments: context.args,
120
+ agent: knobs(agent)
121
+ }, {
122
+ model: '',
123
+ agent,
124
+ messages: [],
125
+ attach: context.attach,
126
+ fetchAttachment: context.fetchAttachment,
127
+ progress: context.progress,
128
+ signal: context.signal
129
+ });
130
+ }
131
+ async *_run(open, context) {
94
132
  const { client } = await this._ensure();
95
133
  let duplex = null;
96
134
  let emittedAborted = false;
@@ -104,32 +142,14 @@ export class RemoteHarness extends EventEmitter {
104
142
  const onAbort = () => duplex?.destroy();
105
143
  try {
106
144
  duplex = client.completion();
107
- duplex.write({
108
- type: 'start',
109
- model: context.model,
110
- agent: knobs(context.agent),
111
- cacheKey: context.cacheKey,
112
- thinkingDefault: context.thinkingDefault,
113
- generationParams: context.generationParams,
114
- responseFormat: context.responseFormat,
115
- captureInferenceCalls: context.captureInferenceCalls,
116
- messages: context.messages.map(({ role, content, attachments }) => ({
117
- role,
118
- content,
119
- attachments: attachments?.map(({ path: file }) => ({ path: file }))
120
- })),
121
- toolConfigs: context.toolConfigs,
122
- grantedDirs: context.grantedDirs,
123
- grantedDrives: context.grantedDrives,
124
- grantedWriteDirs: context.grantedWriteDirs
125
- });
145
+ duplex.write(open);
126
146
  if (context.signal?.aborted) {
127
147
  duplex.destroy();
128
148
  yield { type: 'aborted' };
129
149
  return;
130
150
  }
131
151
  context.signal?.addEventListener?.('abort', onAbort, { once: true });
132
- hlog('client wire open', { model: context.model });
152
+ hlog('client wire open', { model: context.model, capability: open.capability });
133
153
  for await (const event of frames(duplex)) {
134
154
  received++;
135
155
  if (event.type !== 'content' && event.type !== 'thinking') {
@@ -288,7 +308,7 @@ export class RemoteHarness extends EventEmitter {
288
308
  throw died(`harness died mid-run: ${err.message}`);
289
309
  }
290
310
  finally {
291
- hlog('client completion end', { received, aborted: Boolean(context.signal?.aborted) });
311
+ hlog('client run end', { received, aborted: Boolean(context.signal?.aborted) });
292
312
  context.signal?.removeEventListener?.('abort', onAbort);
293
313
  duplex?.destroy();
294
314
  closed = true;
@@ -350,6 +370,14 @@ export class RemoteHarness extends EventEmitter {
350
370
  const response = await abortable(client.transcribe({ model: context.model, audio: context.audio }), context.signal);
351
371
  return response.text;
352
372
  }
373
+ async detectLanguage(context) {
374
+ const { client } = await this._ensure();
375
+ const response = await client.detectLanguage({
376
+ text: context.text,
377
+ topK: context.topK
378
+ });
379
+ return response.languages.map(({ code, language, probability }) => probability ? { code, language, probability } : { code, language });
380
+ }
353
381
  async speak(context) {
354
382
  const { client } = await this._ensure();
355
383
  const response = await abortable(client.speak({ model: context.model, text: context.text }), context.signal);
@@ -656,7 +684,8 @@ function knobs(agent) {
656
684
  imageWidth: agent.imageWidth ?? undefined,
657
685
  imageHeight: agent.imageHeight ?? undefined,
658
686
  approvalMode: agent.approvalMode ?? undefined,
659
- disabledToolsets: agent.disabledToolsets ?? undefined
687
+ disabledToolsets: agent.disabledToolsets ?? undefined,
688
+ sandboxMode: agent.sandboxMode ?? undefined
660
689
  };
661
690
  }
662
691
  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;
@@ -67,6 +68,24 @@ export interface CompletionContext {
67
68
  removeEventListener?: (type: 'abort', fn: () => void) => void;
68
69
  };
69
70
  }
71
+ export interface InvokeContext {
72
+ capability: string;
73
+ args: Record<string, unknown>;
74
+ agent?: AgentKnobs;
75
+ attach?: AttachFn;
76
+ fetchAttachment?: FetchAttachmentFn;
77
+ progress?: ProgressFn;
78
+ signal?: CompletionContext['signal'];
79
+ }
80
+ export interface DetectLanguageContext {
81
+ text: string;
82
+ topK?: number;
83
+ }
84
+ export interface DetectedLanguage {
85
+ code: string;
86
+ language: string;
87
+ probability?: number;
88
+ }
70
89
  export interface HarnessEvent {
71
90
  type: string;
72
91
  text?: string;
@@ -145,10 +164,12 @@ export interface CapabilityInfo {
145
164
  }
146
165
  export interface HarnessContract {
147
166
  completion(context: CompletionContext): AsyncIterable<HarnessEvent>;
167
+ invoke?(context: InvokeContext): AsyncIterable<HarnessEvent>;
148
168
  download?(context: DownloadContext): AsyncIterable<HarnessEvent>;
149
169
  describe?(): Promise<HarnessInfo | null> | HarnessInfo;
150
170
  embed?(context: EmbedContext): Promise<number[][]>;
151
171
  transcribe?(context: TranscribeContext): Promise<string>;
172
+ detectLanguage?(context: DetectLanguageContext): DetectedLanguage[] | Promise<DetectedLanguage[]>;
152
173
  speak?(context: SpeakContext): Promise<SpeakResult>;
153
174
  speakStream?(context: SpeakStreamContext): AsyncIterable<SpeakResult>;
154
175
  getEmbeddingDevice?(): EmbedDevice | Promise<EmbedDevice>;
@@ -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
+ }