@qvac/harness 0.2.10 → 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.
@@ -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)
@@ -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);
@@ -68,6 +68,24 @@ export interface CompletionContext {
68
68
  removeEventListener?: (type: 'abort', fn: () => void) => void;
69
69
  };
70
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
+ }
71
89
  export interface HarnessEvent {
72
90
  type: string;
73
91
  text?: string;
@@ -146,10 +164,12 @@ export interface CapabilityInfo {
146
164
  }
147
165
  export interface HarnessContract {
148
166
  completion(context: CompletionContext): AsyncIterable<HarnessEvent>;
167
+ invoke?(context: InvokeContext): AsyncIterable<HarnessEvent>;
149
168
  download?(context: DownloadContext): AsyncIterable<HarnessEvent>;
150
169
  describe?(): Promise<HarnessInfo | null> | HarnessInfo;
151
170
  embed?(context: EmbedContext): Promise<number[][]>;
152
171
  transcribe?(context: TranscribeContext): Promise<string>;
172
+ detectLanguage?(context: DetectLanguageContext): DetectedLanguage[] | Promise<DetectedLanguage[]>;
153
173
  speak?(context: SpeakContext): Promise<SpeakResult>;
154
174
  speakStream?(context: SpeakStreamContext): AsyncIterable<SpeakResult>;
155
175
  getEmbeddingDevice?(): EmbedDevice | Promise<EmbedDevice>;
@@ -1,3 +1,4 @@
1
+ import { planVendoredInstall } from '../../tools/exec/adapters/python/wheels.js';
1
2
  import { bareBin, firstCommandToken } from '../../tools/exec/scope.js';
2
3
  const NETWORK_BINS = new Set([
3
4
  'curl',
@@ -32,7 +33,8 @@ export class SandboxHook {
32
33
  return;
33
34
  if (!ctx.sandboxMode || ctx.toolName !== 'exec')
34
35
  return;
35
- if (Array.isArray(ctx.args.packages) && ctx.args.packages.length) {
36
+ const packages = ctx.args.packages;
37
+ if (Array.isArray(packages) && packages.length && !planVendoredInstall(packages.map(String))) {
36
38
  return { error: 'sandbox mode: installing packages reaches the network', guidance: true };
37
39
  }
38
40
  const bin = bareBin(firstCommandToken(String(ctx.args.command ?? '')).replace(/^.*[/\\]/, ''));
@@ -4,6 +4,7 @@ import { settleOnClose } from './rpc-session.js';
4
4
  import { pipeline, Readable } from 'streamx';
5
5
  import { bindApi } from '../spec/bind.js';
6
6
  import HRPC from '../spec/hrpc/index.js';
7
+ import { isCapability } from './capabilities/invoke.js';
7
8
  import { unsupportedKvCacheDeletion } from './capabilities/kv-cache.js';
8
9
  import { errorMessage } from './error-fields.js';
9
10
  import { hlog } from './log.js';
@@ -11,7 +12,7 @@ import { pushQueue } from './queue.js';
11
12
  import { TextCoalescer } from './helpers/text-coalescer.js';
12
13
  import { prepareOpenclCache } from './models/opencl-cache.js';
13
14
  import { stageFile, sweepStaging } from './staging.js';
14
- import { readSetup } from './tools/skills/manifest-parse.js';
15
+ import { readSetup, toWireSetup } from './tools/skills/manifest-parse.js';
15
16
  import { packVectors, toDownloadWire, toWire } from './wire.js';
16
17
  const MODEL_STATUSES = new Set([
17
18
  'loading',
@@ -96,6 +97,15 @@ export function serveHarness(stream, harness) {
96
97
  text: await harness.transcribe({ model: model ?? '', audio: audio ?? Buffer.alloc(0) })
97
98
  };
98
99
  },
100
+ detectLanguage: async ({ text, topK }) => {
101
+ if (!harness.detectLanguage)
102
+ throw new Error('harness does not support detectLanguage');
103
+ const languages = await harness.detectLanguage({
104
+ text: text ?? '',
105
+ topK: topK ?? undefined
106
+ });
107
+ return { languages };
108
+ },
99
109
  speak: async ({ model, text }) => {
100
110
  if (!harness.speak)
101
111
  throw new Error('harness does not support speak');
@@ -159,7 +169,7 @@ export function serveHarness(stream, harness) {
159
169
  });
160
170
  }
161
171
  function toWireSkill(skill) {
162
- const setupV2 = readSetup(skill.setup);
172
+ const setupV2 = toWireSetup(readSetup(skill.setup));
163
173
  const binaries = (skill.requires?.bins ?? []).map((bin) => ({
164
174
  bin,
165
175
  minVersion: skill.requires?.binMinVersions?.[bin]?.min,
@@ -275,6 +285,24 @@ function speakStream(harness) {
275
285
  }
276
286
  };
277
287
  }
288
+ function runFrame(harness, frame, context) {
289
+ if (frame.type !== 'invoke')
290
+ return harness.completion(context);
291
+ if (!harness.invoke)
292
+ throw new Error('harness does not support invoke');
293
+ const capability = frame.capability ?? '';
294
+ if (!isCapability(capability))
295
+ throw new Error(`unknown capability: ${capability}`);
296
+ return harness.invoke({
297
+ capability,
298
+ args: frame.arguments ?? {},
299
+ agent: frame.agent ?? undefined,
300
+ attach: context.attach,
301
+ fetchAttachment: context.fetchAttachment,
302
+ progress: context.progress,
303
+ signal: context.signal
304
+ });
305
+ }
278
306
  function completion(harness) {
279
307
  return async function* (duplex) {
280
308
  const events = pushQueue({ highWaterMark: WIRE_HIGH_WATER_MARK });
@@ -404,7 +432,7 @@ function completion(harness) {
404
432
  waiter.resolve((frame.payload ?? {}));
405
433
  return;
406
434
  }
407
- if (frame.type !== 'start' || started)
435
+ if (started || (frame.type !== 'start' && frame.type !== 'invoke'))
408
436
  return;
409
437
  started = true;
410
438
  const context = {
@@ -523,7 +551,7 @@ function completion(harness) {
523
551
  return events.push({ type, text: merged });
524
552
  }, { cap: TEXT_COALESCE_CAP, holdMs: TEXT_COALESCE_MS });
525
553
  try {
526
- for await (const event of harness.completion(context)) {
554
+ for await (const event of runFrame(harness, frame, context)) {
527
555
  produced++;
528
556
  const wire = toWire(event);
529
557
  if ((wire.type === 'content' || wire.type === 'thinking') && wire.text) {
@@ -14,7 +14,7 @@ export function createHttpRequestTool({ fetch: fetchImpl, credentialStore, crede
14
14
  return {
15
15
  name: 'http_request',
16
16
  toolset: 'http-request',
17
- description: 'Make an outbound HTTP or HTTPS request and return the response status and body (truncated). Authorization/Cookie headers are stripped before sending. Stored skill credentials are attached automatically for their allow-listed hosts; `auth` is only needed to force a specific credential key.',
17
+ description: 'Make an outbound HTTP or HTTPS request and return the response status and body (truncated) as text. Authorization/Cookie headers are stripped before sending. Stored skill credentials are attached automatically for their allow-listed hosts; `auth` is only needed to force a specific credential key. For APIs and text responses only: a URL that serves a file — PDF, .docx, .xlsx, an image, any binary — comes back as truncated garbage here. Use web_fetch for those; it downloads and attaches the file.',
18
18
  parameters: {
19
19
  type: 'object',
20
20
  properties: {
@@ -10,3 +10,4 @@ export interface ParsedManifest {
10
10
  export declare function parseManifestBlock(block: string, key?: string): ParsedManifest;
11
11
  export declare function readInstall(v: unknown): InstallStep[] | undefined;
12
12
  export declare function readSetup(v: unknown): SkillSetup | undefined;
13
+ export declare function toWireSetup(setup: SkillSetup | undefined): unknown;
@@ -165,6 +165,9 @@ function readRoutes(v) {
165
165
  const steps = strArr(raw.steps);
166
166
  const configSteps = strArr(raw.configSteps);
167
167
  const fields = readFields(raw.fields);
168
+ const oauth = readOAuth(raw.oauth);
169
+ if (raw.kind === 'oauth' && !oauth)
170
+ continue;
168
171
  routes.push({
169
172
  kind: raw.kind,
170
173
  label: raw.label,
@@ -175,11 +178,99 @@ function readRoutes(v) {
175
178
  ...(steps ? { steps } : {}),
176
179
  ...(configSteps ? { configSteps } : {}),
177
180
  ...(fields ? { fields } : {}),
178
- ...(typeof raw.satisfied === 'boolean' ? { satisfied: raw.satisfied } : {})
181
+ ...(typeof raw.satisfied === 'boolean' ? { satisfied: raw.satisfied } : {}),
182
+ ...(oauth ? { oauth } : {})
179
183
  });
180
184
  }
181
185
  return routes.length > 0 ? routes : undefined;
182
186
  }
187
+ const OAUTH_TOKEN_AUTH = new Set(['pkce-only', 'secret-in-body', 'secret-in-basic']);
188
+ const OAUTH_RESERVED_PARAMS = new Set([
189
+ 'client_id',
190
+ 'client_secret',
191
+ 'redirect_uri',
192
+ 'response_type',
193
+ 'state',
194
+ 'scope',
195
+ 'code_challenge',
196
+ 'code_challenge_method'
197
+ ]);
198
+ function isOAuthTokenAuth(v) {
199
+ return typeof v === 'string' && OAUTH_TOKEN_AUTH.has(v);
200
+ }
201
+ function httpsUrl(v) {
202
+ if (typeof v !== 'string' || v.length === 0)
203
+ return undefined;
204
+ try {
205
+ return new URL(v).protocol === 'https:' ? v : undefined;
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
211
+ function isPort(v) {
212
+ return typeof v === 'number' && Number.isInteger(v) && v > 0 && v < 65536;
213
+ }
214
+ function readAuthParams(v) {
215
+ const raw = Array.isArray(v)
216
+ ? Object.fromEntries(v.flatMap((pair) => isObj(pair) && typeof pair.key === 'string' && typeof pair.value === 'string'
217
+ ? [[pair.key, pair.value]]
218
+ : []))
219
+ : v;
220
+ const record = readStringRecord(raw);
221
+ if (!record)
222
+ return undefined;
223
+ const out = Object.fromEntries(Object.entries(record).filter(([key]) => !OAUTH_RESERVED_PARAMS.has(key)));
224
+ return Object.keys(out).length > 0 ? out : undefined;
225
+ }
226
+ function readOAuth(v) {
227
+ if (!isObj(v))
228
+ return undefined;
229
+ const authUrl = httpsUrl(v.authUrl);
230
+ const tokenUrl = httpsUrl(v.tokenUrl);
231
+ if (typeof v.stateKey !== 'string' ||
232
+ v.stateKey.length === 0 ||
233
+ !authUrl ||
234
+ !tokenUrl ||
235
+ !isOAuthTokenAuth(v.tokenAuth)) {
236
+ return undefined;
237
+ }
238
+ const scopes = v.scopes === undefined || v.scopes === null ? [] : strArr(v.scopes);
239
+ if (!scopes)
240
+ return undefined;
241
+ const extraAuthParams = readAuthParams(v.extraAuthParams);
242
+ const registrationEndpoint = httpsUrl(v.registrationEndpoint);
243
+ return {
244
+ stateKey: v.stateKey,
245
+ authUrl,
246
+ tokenUrl,
247
+ scopes,
248
+ tokenAuth: v.tokenAuth,
249
+ ...(extraAuthParams ? { extraAuthParams } : {}),
250
+ ...(registrationEndpoint ? { registrationEndpoint } : {}),
251
+ ...(isPort(v.port) ? { port: v.port } : {}),
252
+ ...(typeof v.sharedIosSession === 'boolean' ? { sharedIosSession: v.sharedIosSession } : {})
253
+ };
254
+ }
255
+ export function toWireSetup(setup) {
256
+ if (!setup?.routes)
257
+ return setup;
258
+ return {
259
+ ...setup,
260
+ routes: setup.routes.map((route) => route.oauth?.extraAuthParams
261
+ ? {
262
+ ...route,
263
+ oauth: {
264
+ ...route.oauth,
265
+ extraAuthParams: Object.entries(route.oauth.extraAuthParams).map(([key, value]) => ({
266
+ key,
267
+ value
268
+ }))
269
+ }
270
+ }
271
+ : route)
272
+ };
273
+ }
183
274
  function readFields(v) {
184
275
  if (!Array.isArray(v))
185
276
  return undefined;
@@ -29,6 +29,17 @@ export interface SkillSetupField {
29
29
  readonly secret?: boolean;
30
30
  readonly required?: boolean;
31
31
  }
32
+ export interface SkillOAuthConfig {
33
+ readonly stateKey: string;
34
+ readonly authUrl: string;
35
+ readonly tokenUrl: string;
36
+ readonly scopes: readonly string[];
37
+ readonly tokenAuth: 'pkce-only' | 'secret-in-body' | 'secret-in-basic';
38
+ readonly extraAuthParams?: Readonly<Record<string, string>>;
39
+ readonly registrationEndpoint?: string;
40
+ readonly port?: number;
41
+ readonly sharedIosSession?: boolean;
42
+ }
32
43
  export interface SkillSetupRoute {
33
44
  readonly kind: 'oauth' | 'token' | 'install' | 'picker' | 'instructions';
34
45
  readonly label: string;
@@ -40,6 +51,7 @@ export interface SkillSetupRoute {
40
51
  readonly configSteps?: readonly string[];
41
52
  readonly fields?: readonly SkillSetupField[];
42
53
  readonly satisfied?: boolean;
54
+ readonly oauth?: SkillOAuthConfig;
43
55
  }
44
56
  export interface SkillSetup {
45
57
  readonly summary?: string;
@@ -28,7 +28,11 @@ export function createWebFetchTool({ fetch: fetchImpl, resolveHost = lookupHost
28
28
  toolset: 'web-search',
29
29
  description: 'Fetch a public web page and return its readable text. ' +
30
30
  'Use this to read the full content behind a web_search result URL, or a page the user ' +
31
- 'shares, when a search snippet is not enough to answer.',
31
+ 'shares, when a search snippet is not enough to answer. ' +
32
+ 'When the URL serves a file rather than a page — PDF, .docx, .xlsx, .pptx, an image, any ' +
33
+ 'binary — this is also the tool that downloads it: the bytes become a chat attachment and ' +
34
+ 'the result carries its attachmentId. It is the only way to get a remote file into the ' +
35
+ 'chat; exec has no network and http_request truncates binary bodies.',
32
36
  parameters: {
33
37
  type: 'object',
34
38
  properties: {
@@ -178,8 +182,14 @@ async function attachDocument(response, url, mimeType, attach) {
178
182
  raw: !mime.startsWith('image/')
179
183
  });
180
184
  return {
185
+ attachmentId: record.id,
186
+ fileName: record.fileName,
187
+ byteLength: record.byteLength,
181
188
  content: `Attached the file "${record.fileName}" (${Math.round(record.byteLength / 1024)}KB, ${mime}) from ${url.toString()} — ` +
182
- 'the user can open or save it. Its contents were NOT read: do not claim to know what is inside.'
189
+ 'the user can open or save it. Its contents were NOT read, so do not describe what is inside ' +
190
+ 'yet. To read it, stage it with exec as ' +
191
+ `inputs [{ "attachmentId": "${record.id}", "path": "${record.fileName}" }] — never ask the ` +
192
+ 'user to attach a file this call already attached.'
183
193
  };
184
194
  }
185
195
  const MIME_EXTENSIONS = {