@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.6
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/CHANGELOG.md +69 -1
- package/dist/types/config/settings-schema.d.ts +12 -22
- package/dist/types/eval/__tests__/heartbeat.test.d.ts +1 -0
- package/dist/types/eval/heartbeat.d.ts +45 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/loader.d.ts +2 -2
- package/dist/types/extensibility/extensions/runner.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +24 -5
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/internal-urls/local-protocol.d.ts +19 -9
- package/dist/types/internal-urls/types.d.ts +14 -0
- package/dist/types/lsp/client.d.ts +3 -0
- package/dist/types/lsp/diagnostics-ledger.d.ts +10 -0
- package/dist/types/lsp/index.d.ts +2 -0
- package/dist/types/lsp/utils.d.ts +4 -0
- package/dist/types/mcp/manager.d.ts +14 -5
- package/dist/types/modes/acp/acp-agent.d.ts +1 -1
- package/dist/types/modes/components/assistant-message.d.ts +3 -1
- package/dist/types/modes/components/custom-editor.d.ts +0 -1
- package/dist/types/modes/components/hook-selector.d.ts +7 -1
- package/dist/types/modes/controllers/command-controller.d.ts +2 -3
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -2
- package/dist/types/modes/interactive-mode.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +10 -7
- package/dist/types/session/shake-types.d.ts +3 -3
- package/dist/types/task/repair-args.d.ts +52 -0
- package/dist/types/tiny/models.d.ts +0 -14
- package/dist/types/tiny/title-client.d.ts +28 -2
- package/dist/types/tiny/title-protocol.d.ts +8 -9
- package/dist/types/tools/ask.d.ts +8 -6
- package/dist/types/tools/eval-backends.d.ts +12 -0
- package/dist/types/tools/eval-render.d.ts +52 -0
- package/dist/types/tools/eval.d.ts +2 -35
- package/dist/types/tools/find.d.ts +1 -1
- package/dist/types/tools/index.d.ts +4 -11
- package/dist/types/tools/path-utils.d.ts +7 -0
- package/dist/types/tui/output-block.d.ts +11 -10
- package/examples/extensions/README.md +1 -0
- package/examples/extensions/thinking-note.ts +13 -0
- package/package.json +9 -9
- package/scripts/build-binary.ts +0 -1
- package/src/cli.ts +59 -0
- package/src/config/model-registry.ts +33 -4
- package/src/config/settings-schema.ts +13 -24
- package/src/config/settings.ts +10 -0
- package/src/discovery/claude.ts +41 -22
- package/src/edit/index.ts +23 -3
- package/src/eval/__tests__/agent-bridge.test.ts +90 -0
- package/src/eval/__tests__/heartbeat.test.ts +84 -0
- package/src/eval/__tests__/llm-bridge.test.ts +30 -0
- package/src/eval/agent-bridge.ts +44 -38
- package/src/eval/heartbeat.ts +74 -0
- package/src/eval/js/executor.ts +13 -9
- package/src/eval/llm-bridge.ts +20 -14
- package/src/eval/py/executor.ts +14 -18
- package/src/exec/bash-executor.ts +31 -5
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/loader.ts +16 -18
- package/src/extensibility/extensions/runner.ts +22 -17
- package/src/extensibility/extensions/types.ts +39 -5
- package/src/extensibility/shared-events.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/internal-urls/local-protocol.ts +23 -11
- package/src/internal-urls/types.ts +15 -0
- package/src/lsp/client.ts +28 -5
- package/src/lsp/diagnostics-ledger.ts +51 -0
- package/src/lsp/index.ts +9 -22
- package/src/lsp/utils.ts +21 -0
- package/src/mcp/manager.ts +87 -4
- package/src/modes/acp/acp-agent.ts +8 -4
- package/src/modes/components/assistant-message.ts +28 -1
- package/src/modes/components/custom-editor.ts +9 -7
- package/src/modes/components/hook-selector.ts +159 -32
- package/src/modes/components/tool-execution.ts +20 -4
- package/src/modes/controllers/command-controller.ts +7 -39
- package/src/modes/controllers/event-controller.ts +38 -28
- package/src/modes/controllers/extension-ui-controller.ts +3 -2
- package/src/modes/controllers/input-controller.ts +0 -15
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +2 -1
- package/src/modes/rpc/rpc-mode.ts +17 -6
- package/src/modes/theme/theme-schema.json +30 -0
- package/src/modes/theme/theme.ts +39 -2
- package/src/modes/types.ts +2 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/prompts/system/project-prompt.md +3 -2
- package/src/prompts/system/subagent-system-prompt.md +12 -8
- package/src/prompts/system/system-prompt.md +8 -6
- package/src/prompts/tools/ask.md +2 -1
- package/src/prompts/tools/eval.md +1 -1
- package/src/session/agent-session.ts +75 -103
- package/src/session/shake-types.ts +4 -5
- package/src/slash-commands/builtin-registry.ts +2 -4
- package/src/task/executor.ts +14 -4
- package/src/task/index.ts +3 -2
- package/src/task/repair-args.ts +117 -0
- package/src/tiny/models.ts +0 -28
- package/src/tiny/title-client.ts +133 -43
- package/src/tiny/title-protocol.ts +11 -16
- package/src/tiny/worker.ts +6 -61
- package/src/tools/ask.ts +74 -32
- package/src/tools/ast-edit.ts +3 -0
- package/src/tools/ast-grep.ts +3 -0
- package/src/tools/eval-backends.ts +38 -0
- package/src/tools/eval-render.ts +750 -0
- package/src/tools/eval.ts +27 -754
- package/src/tools/find.ts +20 -6
- package/src/tools/gh.ts +1 -0
- package/src/tools/index.ts +7 -37
- package/src/tools/path-utils.ts +13 -2
- package/src/tools/read.ts +1 -0
- package/src/tools/renderers.ts +1 -1
- package/src/tools/search.ts +12 -1
- package/src/tools/write.ts +9 -1
- package/src/tui/output-block.ts +42 -79
- package/src/utils/git.ts +9 -3
|
@@ -62,9 +62,8 @@ const shutdownHandlerTui = (_command: ParsedSlashCommand, runtime: TuiSlashComma
|
|
|
62
62
|
function parseShakeMode(args: string): ShakeMode | { error: string } {
|
|
63
63
|
const verb = args.trim().toLowerCase();
|
|
64
64
|
if (verb === "" || verb === "elide") return "elide";
|
|
65
|
-
if (verb === "summary") return "summary";
|
|
66
65
|
if (verb === "images") return "images";
|
|
67
|
-
return { error: `Unknown /shake mode "${verb}". Use elide
|
|
66
|
+
return { error: `Unknown /shake mode "${verb}". Use elide or images.` };
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
@@ -826,10 +825,9 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
826
825
|
acpDescription: "Shake heavy content out of the conversation context",
|
|
827
826
|
subcommands: [
|
|
828
827
|
{ name: "elide", description: "Strip tool results + large blocks (default)" },
|
|
829
|
-
{ name: "summary", description: "Compress heavy regions with a local on-device model" },
|
|
830
828
|
{ name: "images", description: "Strip image blocks" },
|
|
831
829
|
],
|
|
832
|
-
acpInputHint: "[elide|
|
|
830
|
+
acpInputHint: "[elide|images]",
|
|
833
831
|
allowArgs: true,
|
|
834
832
|
handle: async (command, runtime) => {
|
|
835
833
|
const mode = parseShakeMode(command.args);
|
package/src/task/executor.ts
CHANGED
|
@@ -633,7 +633,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
633
633
|
if (atMaxDepth && toolNames?.includes("task")) {
|
|
634
634
|
toolNames = toolNames.filter(name => name !== "task");
|
|
635
635
|
}
|
|
636
|
-
// IRC is always available; the
|
|
636
|
+
// IRC is always available; the COOP prompt section advertises it, so a restricted
|
|
637
637
|
// whitelist must still carry `irc` for the subagent to actually use it.
|
|
638
638
|
if (toolNames && !toolNames.includes("irc")) {
|
|
639
639
|
toolNames = [...toolNames, "irc"];
|
|
@@ -1446,9 +1446,19 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
|
|
|
1446
1446
|
);
|
|
1447
1447
|
await awaitAbortable(session.waitForIdle());
|
|
1448
1448
|
} catch (err) {
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1449
|
+
if (abortSignal.aborted || err instanceof ToolAbortError) {
|
|
1450
|
+
// Benign control-flow exit — user cancel (^C) or compaction aborting
|
|
1451
|
+
// pending operations both surface here as ToolAbortError. The outer
|
|
1452
|
+
// catch and finally already mark the run aborted; logging at ERROR
|
|
1453
|
+
// would spam operator dashboards with non-failures.
|
|
1454
|
+
logger.debug("Subagent prompt aborted", {
|
|
1455
|
+
reason: abortReason ?? "signal",
|
|
1456
|
+
});
|
|
1457
|
+
} else {
|
|
1458
|
+
logger.error("Subagent prompt failed", {
|
|
1459
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1452
1462
|
}
|
|
1453
1463
|
}
|
|
1454
1464
|
|
package/src/task/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { runSubprocess } from "./executor";
|
|
|
48
48
|
import { AgentOutputManager } from "./output-manager";
|
|
49
49
|
import { mapWithConcurrencyLimit, Semaphore } from "./parallel";
|
|
50
50
|
import { renderResult, renderCall as renderTaskCall } from "./render";
|
|
51
|
+
import { repairTaskParams } from "./repair-args";
|
|
51
52
|
import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
|
|
52
53
|
import {
|
|
53
54
|
applyNestedPatches,
|
|
@@ -247,7 +248,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
247
248
|
}
|
|
248
249
|
|
|
249
250
|
renderCall(args: unknown, options: Parameters<typeof renderTaskCall>[1], theme: Theme) {
|
|
250
|
-
return renderTaskCall(args as TaskParams, options, theme);
|
|
251
|
+
return renderTaskCall(repairTaskParams(args as TaskParams), options, theme);
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
/** Dynamic description that reflects current disabled-agent settings */
|
|
@@ -292,7 +293,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
|
|
|
292
293
|
signal?: AbortSignal,
|
|
293
294
|
onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
|
|
294
295
|
): Promise<AgentToolResult<TaskToolDetails>> {
|
|
295
|
-
const params = rawParams as TaskParams;
|
|
296
|
+
const params = repairTaskParams(rawParams as TaskParams);
|
|
296
297
|
const simpleMode = this.#getTaskSimpleMode();
|
|
297
298
|
const validationError = validateTaskModeParams(simpleMode, params);
|
|
298
299
|
if (validationError) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair double-encoded JSON string arguments for the task tool.
|
|
3
|
+
*
|
|
4
|
+
* Models occasionally JSON-escape a string value twice when emitting a
|
|
5
|
+
* `task` tool call, so a `context`/`assignment` that should read
|
|
6
|
+
*
|
|
7
|
+
* # Role
|
|
8
|
+
* You are a judge … "describe this" … return —
|
|
9
|
+
*
|
|
10
|
+
* arrives — after the one JSON decode the provider already applied — as the
|
|
11
|
+
* literal text
|
|
12
|
+
*
|
|
13
|
+
* # Role\nYou are a judge … \"describe this\" … return \u2014
|
|
14
|
+
*
|
|
15
|
+
* i.e. every newline, quote, and unicode character is still backslash-escaped.
|
|
16
|
+
* The subagent then receives that garbled prompt, and the call preview renders
|
|
17
|
+
* one long blob with visible `\n` / `\"` / `\uXXXX`.
|
|
18
|
+
*
|
|
19
|
+
* The *whole-arguments* form of this quirk (the entire `arguments` blob is a
|
|
20
|
+
* JSON string) is already auto-corrected by the validator's JSON-string
|
|
21
|
+
* coercion. This module handles the *per-field* form, where the object parses
|
|
22
|
+
* fine but an individual string value is double-encoded — the validator never
|
|
23
|
+
* fires there because a double-encoded string is still a structurally valid
|
|
24
|
+
* string.
|
|
25
|
+
*
|
|
26
|
+
* This is deliberately scoped to the task tool's natural-language fields
|
|
27
|
+
* (`context`, `assignment`, `description`). It is NOT applied to code-bearing
|
|
28
|
+
* tools (write/edit/bash/search), where a backslash or quote is load-bearing
|
|
29
|
+
* and a false-positive unescape would silently corrupt a file or command.
|
|
30
|
+
*/
|
|
31
|
+
import type { TaskItem, TaskParams } from "./types";
|
|
32
|
+
|
|
33
|
+
/** A backslash that escapes a structural char — `\"`, `\\`, `\/`, or `\uXXXX`. */
|
|
34
|
+
const STRUCTURAL_ESCAPE = /\\(?:["\\/]|u[0-9a-fA-F]{4})/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether `value` carries the signature of whole-string double-encoding rather
|
|
38
|
+
* than an incidental escape mention. A lone `\n`/`\t` in an instruction (e.g.
|
|
39
|
+
* "split lines on \n") is far more likely a literal mention than a
|
|
40
|
+
* double-encoded document, so it is left alone; a structural escape (`\"`,
|
|
41
|
+
* `\\`, `\uXXXX`) or two-plus escape sequences indicates a re-escaped payload.
|
|
42
|
+
*/
|
|
43
|
+
function hasDoubleEncodeSignature(value: string): boolean {
|
|
44
|
+
if (STRUCTURAL_ESCAPE.test(value)) return true;
|
|
45
|
+
let count = 0;
|
|
46
|
+
for (let i = 0; i < value.length; i++) {
|
|
47
|
+
if (value.charCodeAt(i) === 0x5c /* \ */) {
|
|
48
|
+
count += 1;
|
|
49
|
+
if (count >= 2) return true;
|
|
50
|
+
i += 1; // skip the escaped char so `\\` counts once
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return the once-unescaped string when `value` is uniformly double-encoded
|
|
58
|
+
* JSON (a well-formed JSON string body that decodes to a different string);
|
|
59
|
+
* otherwise return `value` unchanged.
|
|
60
|
+
*
|
|
61
|
+
* The `JSON.parse(\`"${value}"\`)` round-trip is the safety net: it only
|
|
62
|
+
* succeeds when *every* backslash begins a valid JSON escape and no bare
|
|
63
|
+
* double-quote exists — exactly the signature of double-encoding. Genuine
|
|
64
|
+
* prose with a Windows path (`C:\Users`), a regex (`\d+`), an embedded quote,
|
|
65
|
+
* or a real (already-decoded) newline makes the parse throw, so the value is
|
|
66
|
+
* returned untouched.
|
|
67
|
+
*/
|
|
68
|
+
export function repairDoubleEncodedJsonString(value: string): string {
|
|
69
|
+
// Fast path: no backslash → nothing was escaped → the parse can never differ.
|
|
70
|
+
if (!value.includes("\\")) return value;
|
|
71
|
+
if (!hasDoubleEncodeSignature(value)) return value;
|
|
72
|
+
let decoded: unknown;
|
|
73
|
+
try {
|
|
74
|
+
decoded = JSON.parse(`"${value}"`);
|
|
75
|
+
} catch {
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
return typeof decoded === "string" && decoded !== value ? decoded : value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Repair a single (possibly partial) task item's prose fields. */
|
|
82
|
+
function repairTaskItem(task: TaskItem): TaskItem {
|
|
83
|
+
if (task === null || typeof task !== "object") return task;
|
|
84
|
+
const assignment =
|
|
85
|
+
typeof task.assignment === "string" ? repairDoubleEncodedJsonString(task.assignment) : task.assignment;
|
|
86
|
+
const description =
|
|
87
|
+
typeof task.description === "string" ? repairDoubleEncodedJsonString(task.description) : task.description;
|
|
88
|
+
if (assignment === task.assignment && description === task.description) return task;
|
|
89
|
+
return { ...task, assignment, description };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Repair double-encoded prose in task-tool params (`context` and each task's
|
|
94
|
+
* `assignment`/`description`). Returns the same reference when nothing changed
|
|
95
|
+
* so callers can cheaply skip work. Defensive against partially-streamed args
|
|
96
|
+
* (missing/undefined fields, partial task arrays) so it is safe on the render
|
|
97
|
+
* path as well as on execution.
|
|
98
|
+
*/
|
|
99
|
+
export function repairTaskParams(params: TaskParams): TaskParams {
|
|
100
|
+
if (params === null || typeof params !== "object") return params;
|
|
101
|
+
|
|
102
|
+
const context = typeof params.context === "string" ? repairDoubleEncodedJsonString(params.context) : params.context;
|
|
103
|
+
|
|
104
|
+
let tasks = params.tasks;
|
|
105
|
+
if (Array.isArray(params.tasks)) {
|
|
106
|
+
let changed = false;
|
|
107
|
+
const repaired = params.tasks.map(task => {
|
|
108
|
+
const next = repairTaskItem(task);
|
|
109
|
+
if (next !== task) changed = true;
|
|
110
|
+
return next;
|
|
111
|
+
});
|
|
112
|
+
if (changed) tasks = repaired;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (context === params.context && tasks === params.tasks) return params;
|
|
116
|
+
return { ...params, context, tasks };
|
|
117
|
+
}
|
package/src/tiny/models.ts
CHANGED
|
@@ -196,34 +196,6 @@ export function getTinyMemoryModelSpec(key: TinyMemoryLocalModelKey): (typeof TI
|
|
|
196
196
|
return spec;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/**
|
|
200
|
-
* Shake-summary models. Shake's `summary` mode (and the `shake-summary`
|
|
201
|
-
* compaction strategy) compress heavy regions strictly on-device — there is no
|
|
202
|
-
* online/remote option, so this registry reuses the local memory models only.
|
|
203
|
-
*/
|
|
204
|
-
export const SHAKE_SUMMARY_MODEL_VALUES = [
|
|
205
|
-
"qwen3-1.7b",
|
|
206
|
-
"gemma-3-1b",
|
|
207
|
-
"qwen2.5-1.5b",
|
|
208
|
-
"lfm2-1.2b",
|
|
209
|
-
] as const satisfies readonly TinyMemoryLocalModelKey[];
|
|
210
|
-
|
|
211
|
-
export type ShakeSummaryModelKey = (typeof SHAKE_SUMMARY_MODEL_VALUES)[number];
|
|
212
|
-
|
|
213
|
-
// Guard: every local memory model is offered for shake summary (catches drift).
|
|
214
|
-
type MissingShakeSummaryValue = Exclude<TinyMemoryLocalModelKey, ShakeSummaryModelKey>;
|
|
215
|
-
const SHAKE_SUMMARY_MODEL_VALUES_MATCH_REGISTRY: MissingShakeSummaryValue extends never ? true : never = true;
|
|
216
|
-
void SHAKE_SUMMARY_MODEL_VALUES_MATCH_REGISTRY;
|
|
217
|
-
|
|
218
|
-
export const SHAKE_SUMMARY_MODEL_OPTIONS = TINY_MEMORY_LOCAL_MODELS.map(model => ({
|
|
219
|
-
value: model.key,
|
|
220
|
-
label: model.label,
|
|
221
|
-
description: model.description,
|
|
222
|
-
})) satisfies ReadonlyArray<{ value: ShakeSummaryModelKey; label: string; description: string }>;
|
|
223
|
-
|
|
224
|
-
/** Default shake-summary local model when none is named. */
|
|
225
|
-
export const DEFAULT_SHAKE_SUMMARY_MODEL_KEY: ShakeSummaryModelKey = DEFAULT_MEMORY_LOCAL_MODEL_KEY;
|
|
226
|
-
|
|
227
199
|
/** Any local model key (title or memory), used by the shared inference worker. */
|
|
228
200
|
export type TinyLocalModelKey = TinyTitleLocalModelKey | TinyMemoryLocalModelKey;
|
|
229
201
|
|
package/src/tiny/title-client.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
1
2
|
import { $env, isCompiledBinary, logger } from "@oh-my-pi/pi-utils";
|
|
3
|
+
import type { Subprocess } from "bun";
|
|
2
4
|
import { settings } from "../config/settings";
|
|
3
5
|
import { tinyModelDeviceSettingToEnv } from "./device";
|
|
4
6
|
import { tinyModelDtypeSettingToEnv } from "./dtype";
|
|
@@ -12,6 +14,14 @@ import {
|
|
|
12
14
|
} from "./models";
|
|
13
15
|
import type { TinyTitleProgressEvent, TinyTitleWorkerInbound, TinyTitleWorkerOutbound } from "./title-protocol";
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Abstraction over the tiny-model subprocess. Modelled as a worker interface
|
|
19
|
+
* so existing callers (titles, memory completions, downloads) compose the
|
|
20
|
+
* same way; the runtime implementation is a Bun child process so
|
|
21
|
+
* `onnxruntime-node`'s NAPI finalizer never runs inside the main agent
|
|
22
|
+
* address space — that destructor segfaults Bun on Windows during shutdown
|
|
23
|
+
* (issue #1606).
|
|
24
|
+
*/
|
|
15
25
|
interface WorkerHandle {
|
|
16
26
|
send(message: TinyTitleWorkerInbound): void;
|
|
17
27
|
onMessage(handler: (message: TinyTitleWorkerOutbound) => void): () => void;
|
|
@@ -31,6 +41,12 @@ export interface TinyTitleDownloadOptions {
|
|
|
31
41
|
|
|
32
42
|
const SMOKE_TEST_TIMEOUT_MS = 5_000;
|
|
33
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Hidden subcommand on the main CLI that boots the tiny-model worker in the
|
|
46
|
+
* spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
|
|
47
|
+
*/
|
|
48
|
+
export const TINY_WORKER_ARG = "--tiny-worker";
|
|
49
|
+
|
|
34
50
|
function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
|
|
35
51
|
try {
|
|
36
52
|
const value = settings.get(path);
|
|
@@ -66,49 +82,128 @@ export function tinyWorkerEnvOverlay(
|
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/**
|
|
69
|
-
* Env handed to the tiny-model
|
|
70
|
-
* vars win; otherwise the persisted `providers.tinyModelDevice` /
|
|
71
|
-
* `providers.tinyModelDtype` settings are mapped onto those vars so the
|
|
72
|
-
* env-based resolution picks them up. Resolved once at spawn
|
|
85
|
+
* Env handed to the tiny-model subprocess. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE`
|
|
86
|
+
* env vars win; otherwise the persisted `providers.tinyModelDevice` /
|
|
87
|
+
* `providers.tinyModelDtype` settings are mapped onto those vars so the
|
|
88
|
+
* subprocess's env-based resolution picks them up. Resolved once at spawn
|
|
89
|
+
* (pipelines are cached for the lifetime of the subprocess).
|
|
73
90
|
*/
|
|
74
|
-
function tinyWorkerEnv(): Record<string, string>
|
|
91
|
+
function tinyWorkerEnv(): Record<string, string> {
|
|
75
92
|
const overlay = tinyWorkerEnvOverlay(
|
|
76
93
|
$env,
|
|
77
94
|
readTinyModelSetting("providers.tinyModelDevice"),
|
|
78
95
|
readTinyModelSetting("providers.tinyModelDtype"),
|
|
79
96
|
);
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
const base = $env as Record<string, string | undefined>;
|
|
98
|
+
const merged: Record<string, string> = {};
|
|
99
|
+
for (const key in base) {
|
|
100
|
+
const value = base[key];
|
|
101
|
+
if (typeof value === "string") merged[key] = value;
|
|
102
|
+
}
|
|
103
|
+
for (const key in overlay) merged[key] = overlay[key];
|
|
104
|
+
return merged;
|
|
82
105
|
}
|
|
83
106
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the argv used to relaunch the agent CLI into tiny-worker mode. In a
|
|
109
|
+
* compiled binary the entry point is the binary itself; in dev/source the
|
|
110
|
+
* spawned `bun` needs the absolute path to `cli.ts` so it can resolve module
|
|
111
|
+
* imports against the on-disk source tree.
|
|
112
|
+
*/
|
|
113
|
+
function tinyWorkerSpawnCmd(): string[] {
|
|
114
|
+
if (isCompiledBinary()) return [process.execPath, TINY_WORKER_ARG];
|
|
115
|
+
const cliPath = path.resolve(import.meta.dir, "..", "cli.ts");
|
|
116
|
+
return [process.execPath, cliPath, TINY_WORKER_ARG];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface SpawnedSubprocess {
|
|
120
|
+
proc: Subprocess<"ignore", "inherit", "inherit">;
|
|
121
|
+
inbound: Set<(message: TinyTitleWorkerOutbound) => void>;
|
|
122
|
+
errors: Set<(error: Error) => void>;
|
|
123
|
+
/**
|
|
124
|
+
* Flipped to `true` by {@link wrapSubprocess}'s `terminate()` right
|
|
125
|
+
* before it SIGKILLs the child so `onExit` can distinguish the
|
|
126
|
+
* expected hard-kill from a crash/OOM/external signal. Only the
|
|
127
|
+
* latter is surfaced as a worker error.
|
|
128
|
+
*/
|
|
129
|
+
intentionalExit: { value: boolean };
|
|
90
130
|
}
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Spawn the tiny-model worker as a subprocess. Exported for tests and the
|
|
134
|
+
* smoke probe; production callers go through {@link spawnTinyTitleWorker}
|
|
135
|
+
* which wraps the result in a {@link WorkerHandle}.
|
|
136
|
+
*/
|
|
137
|
+
export function createTinyTitleSubprocess(): SpawnedSubprocess {
|
|
138
|
+
const inbound = new Set<(message: TinyTitleWorkerOutbound) => void>();
|
|
139
|
+
const errors = new Set<(error: Error) => void>();
|
|
140
|
+
const intentionalExit = { value: false };
|
|
141
|
+
const proc = Bun.spawn({
|
|
142
|
+
cmd: tinyWorkerSpawnCmd(),
|
|
143
|
+
env: tinyWorkerEnv(),
|
|
144
|
+
stdin: "ignore",
|
|
145
|
+
stdout: "inherit",
|
|
146
|
+
stderr: "inherit",
|
|
147
|
+
serialization: "advanced",
|
|
148
|
+
windowsHide: true,
|
|
149
|
+
ipc(message) {
|
|
150
|
+
for (const handler of inbound) handler(message as TinyTitleWorkerOutbound);
|
|
151
|
+
},
|
|
152
|
+
onExit(_proc, exitCode, signalCode) {
|
|
153
|
+
// Clean exit. The child only exits via SIGKILL in practice, but
|
|
154
|
+
// treat code 0 as a no-op for symmetry.
|
|
155
|
+
if (exitCode === 0) return;
|
|
156
|
+
// `exitCode === null` + non-null `signalCode` covers both the
|
|
157
|
+
// expected SIGKILL from `terminate()` AND external kills
|
|
158
|
+
// (SIGSEGV from a native crash, SIGKILL from the OOM killer, an
|
|
159
|
+
// operator `kill -9`, etc.). Swallow only the expected one;
|
|
160
|
+
// every other signal exit is a real worker death that must
|
|
161
|
+
// fault every in-flight request so callers don't await forever.
|
|
162
|
+
if (exitCode === null && intentionalExit.value) return;
|
|
163
|
+
const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
|
|
164
|
+
const err = new Error(`tiny model subprocess exited with ${reason}`);
|
|
165
|
+
for (const handler of errors) handler(err);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
// Don't keep the parent event loop alive on account of an idle worker; the
|
|
169
|
+
// agent dispose path calls `terminate()` explicitly when shutting down.
|
|
170
|
+
proc.unref();
|
|
171
|
+
return { proc, inbound, errors, intentionalExit };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
|
|
94
175
|
return {
|
|
95
176
|
send(message) {
|
|
96
|
-
|
|
177
|
+
try {
|
|
178
|
+
proc.send(message);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
logger.debug("tiny-title: send to subprocess failed", {
|
|
181
|
+
error: error instanceof Error ? error.message : String(error),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
97
184
|
},
|
|
98
185
|
onMessage(handler) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return () => worker.removeEventListener("message", wrap);
|
|
186
|
+
inbound.add(handler);
|
|
187
|
+
return () => inbound.delete(handler);
|
|
102
188
|
},
|
|
103
189
|
onError(handler) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
};
|
|
107
|
-
worker.addEventListener("error", wrap);
|
|
108
|
-
return () => worker.removeEventListener("error", wrap);
|
|
190
|
+
errors.add(handler);
|
|
191
|
+
return () => errors.delete(handler);
|
|
109
192
|
},
|
|
110
193
|
async terminate() {
|
|
111
|
-
|
|
194
|
+
// SIGKILL: the whole point of the subprocess isolation is that the
|
|
195
|
+
// parent never runs `onnxruntime-node`'s NAPI finalizer. A polite
|
|
196
|
+
// SIGTERM lets the subprocess try to clean up, which is exactly the
|
|
197
|
+
// codepath that crashes Bun on Windows. Hard-kill instead — the
|
|
198
|
+
// model lives in process memory and the OS reclaims everything.
|
|
199
|
+
// Flip the intentional-exit flag *before* killing so `onExit` can
|
|
200
|
+
// tell this apart from a crash or external SIGKILL.
|
|
201
|
+
intentionalExit.value = true;
|
|
202
|
+
try {
|
|
203
|
+
proc.kill("SIGKILL");
|
|
204
|
+
} catch {
|
|
205
|
+
// Already gone.
|
|
206
|
+
}
|
|
112
207
|
},
|
|
113
208
|
};
|
|
114
209
|
}
|
|
@@ -126,10 +221,6 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
|
|
|
126
221
|
emit({ type: "pong", id: message.id });
|
|
127
222
|
return;
|
|
128
223
|
}
|
|
129
|
-
if (message.type === "close") {
|
|
130
|
-
emit({ type: "closed" });
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
224
|
emit({ type: "error", id: message.id, error: errorMessage });
|
|
134
225
|
});
|
|
135
226
|
},
|
|
@@ -148,9 +239,9 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
|
|
|
148
239
|
|
|
149
240
|
function spawnTinyTitleWorker(): WorkerHandle {
|
|
150
241
|
try {
|
|
151
|
-
return
|
|
242
|
+
return wrapSubprocess(createTinyTitleSubprocess());
|
|
152
243
|
} catch (error) {
|
|
153
|
-
logger.warn("Tiny title
|
|
244
|
+
logger.warn("Tiny title worker spawn failed; local titles disabled", {
|
|
154
245
|
error: error instanceof Error ? error.message : String(error),
|
|
155
246
|
});
|
|
156
247
|
return spawnInlineUnavailableWorker(error);
|
|
@@ -293,9 +384,9 @@ export class TinyTitleClient {
|
|
|
293
384
|
}
|
|
294
385
|
this.#pending.clear();
|
|
295
386
|
try {
|
|
296
|
-
worker?.
|
|
387
|
+
await worker?.terminate();
|
|
297
388
|
} catch {
|
|
298
|
-
//
|
|
389
|
+
// Already gone.
|
|
299
390
|
}
|
|
300
391
|
}
|
|
301
392
|
|
|
@@ -317,7 +408,6 @@ export class TinyTitleClient {
|
|
|
317
408
|
this.#emitProgress(message.event);
|
|
318
409
|
return;
|
|
319
410
|
}
|
|
320
|
-
if (message.type === "closed") return;
|
|
321
411
|
if (message.type === "pong") return;
|
|
322
412
|
|
|
323
413
|
const pending = this.#pending.get(message.id);
|
|
@@ -371,25 +461,25 @@ export async function smokeTestTinyTitleWorker({
|
|
|
371
461
|
}: {
|
|
372
462
|
timeoutMs?: number;
|
|
373
463
|
} = {}): Promise<void> {
|
|
374
|
-
const
|
|
464
|
+
const handle = wrapSubprocess(createTinyTitleSubprocess());
|
|
375
465
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
|
376
466
|
const timer = setTimeout(() => reject(new Error(`tiny title worker did not pong within ${timeoutMs}ms`)), timeoutMs);
|
|
377
|
-
|
|
378
|
-
const message = event.data;
|
|
467
|
+
const unsubscribeMessage = handle.onMessage(message => {
|
|
379
468
|
if (message.type === "pong") {
|
|
380
469
|
resolve();
|
|
381
470
|
return;
|
|
382
471
|
}
|
|
472
|
+
if (message.type === "log") return;
|
|
383
473
|
reject(new Error(`tiny title worker: expected pong, got ${JSON.stringify(message)}`));
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
reject(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
|
|
387
|
-
};
|
|
474
|
+
});
|
|
475
|
+
const unsubscribeError = handle.onError(reject);
|
|
388
476
|
try {
|
|
389
|
-
|
|
477
|
+
handle.send({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
|
|
390
478
|
await promise;
|
|
391
479
|
} finally {
|
|
392
480
|
clearTimeout(timer);
|
|
393
|
-
|
|
481
|
+
unsubscribeMessage();
|
|
482
|
+
unsubscribeError();
|
|
483
|
+
await handle.terminate();
|
|
394
484
|
}
|
|
395
485
|
}
|
|
@@ -30,19 +30,8 @@ export interface TinyTitleProgressEvent {
|
|
|
30
30
|
export type TinyTitleWorkerInbound =
|
|
31
31
|
| { type: "ping"; id: string }
|
|
32
32
|
| { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
|
|
33
|
-
| {
|
|
34
|
-
|
|
35
|
-
id: string;
|
|
36
|
-
modelKey: TinyLocalModelKey;
|
|
37
|
-
prompt: string;
|
|
38
|
-
maxTokens?: number;
|
|
39
|
-
/** Optional assistant-turn prefix appended after the generation prompt to pin output format. */
|
|
40
|
-
prefill?: string;
|
|
41
|
-
/** Optional literal stop string; generation halts once it appears in the decoded tail. */
|
|
42
|
-
stop?: string;
|
|
43
|
-
}
|
|
44
|
-
| { type: "download"; id: string; modelKey: TinyLocalModelKey }
|
|
45
|
-
| { type: "close" };
|
|
33
|
+
| { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
|
|
34
|
+
| { type: "download"; id: string; modelKey: TinyLocalModelKey };
|
|
46
35
|
|
|
47
36
|
export type TinyTitleWorkerOutbound =
|
|
48
37
|
| { type: "pong"; id: string }
|
|
@@ -51,11 +40,17 @@ export type TinyTitleWorkerOutbound =
|
|
|
51
40
|
| { type: "downloaded"; id: string }
|
|
52
41
|
| { type: "error"; id: string; error: string }
|
|
53
42
|
| { type: "progress"; id: string; event: TinyTitleProgressEvent }
|
|
54
|
-
| { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
|
|
55
|
-
| { type: "closed" };
|
|
43
|
+
| { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> };
|
|
56
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Wire transport between the parent (`TinyTitleClient`) and the tiny-model
|
|
47
|
+
* subprocess. The parent owns the subprocess lifecycle (graceful work, hard
|
|
48
|
+
* kill on shutdown); the protocol therefore carries no explicit close
|
|
49
|
+
* handshake — once the parent decides to terminate, it signals the OS to
|
|
50
|
+
* reap the child so `onnxruntime-node`'s NAPI finalizer never runs in any
|
|
51
|
+
* shared address space. See `title-client.ts` for the spawn/kill glue.
|
|
52
|
+
*/
|
|
57
53
|
export interface TinyTitleTransport {
|
|
58
54
|
send(message: TinyTitleWorkerOutbound): void;
|
|
59
55
|
onMessage(handler: (message: TinyTitleWorkerInbound) => void): () => void;
|
|
60
|
-
close(): void;
|
|
61
56
|
}
|
package/src/tiny/worker.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { parentPort } from "node:worker_threads";
|
|
5
4
|
import type {
|
|
6
5
|
ProgressInfo,
|
|
7
6
|
TextGenerationPipeline,
|
|
@@ -20,12 +19,7 @@ import {
|
|
|
20
19
|
type TinyTitleLocalModelSpec,
|
|
21
20
|
} from "./models";
|
|
22
21
|
import { formatTitleUserMessage, normalizeGeneratedTitle } from "./text";
|
|
23
|
-
import type {
|
|
24
|
-
TinyTitleProgressEvent,
|
|
25
|
-
TinyTitleTransport,
|
|
26
|
-
TinyTitleWorkerInbound,
|
|
27
|
-
TinyTitleWorkerOutbound,
|
|
28
|
-
} from "./title-protocol";
|
|
22
|
+
import type { TinyTitleProgressEvent, TinyTitleTransport, TinyTitleWorkerInbound } from "./title-protocol";
|
|
29
23
|
|
|
30
24
|
const TITLE_PREFILL = "<title>";
|
|
31
25
|
const TITLE_CLOSE = "</title>";
|
|
@@ -461,15 +455,14 @@ async function generateTitle(
|
|
|
461
455
|
return extractTinyTitle(output[0]?.generated_text ?? "");
|
|
462
456
|
}
|
|
463
457
|
|
|
464
|
-
function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string
|
|
458
|
+
function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string): string {
|
|
465
459
|
const chat = [{ role: "user", content: promptText }];
|
|
466
460
|
const chatTemplateOptions = {
|
|
467
461
|
add_generation_prompt: true,
|
|
468
462
|
tokenize: false,
|
|
469
463
|
enable_thinking: false,
|
|
470
464
|
};
|
|
471
|
-
|
|
472
|
-
return prefill ? `${base}${prefill}` : base;
|
|
465
|
+
return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}`;
|
|
473
466
|
}
|
|
474
467
|
|
|
475
468
|
/**
|
|
@@ -484,37 +477,18 @@ async function generateCompletion(
|
|
|
484
477
|
modelKey: TinyLocalModelKey,
|
|
485
478
|
promptText: string,
|
|
486
479
|
maxTokens: number | undefined,
|
|
487
|
-
prefill?: string,
|
|
488
|
-
stop?: string,
|
|
489
480
|
): Promise<string | null> {
|
|
490
481
|
const generator = await loadPipeline(modelKey, transport, requestId);
|
|
491
|
-
const text = buildCompletionPrompt(generator, promptText
|
|
482
|
+
const text = buildCompletionPrompt(generator, promptText);
|
|
492
483
|
const requested = maxTokens ?? MEMORY_COMPLETION_MAX_NEW_TOKENS;
|
|
493
484
|
const maxNewTokens = Math.min(Math.max(1, requested), MEMORY_COMPLETION_MAX_NEW_TOKENS);
|
|
494
|
-
const transformers = stop ? await loadTransformers(transport, requestId, modelKey) : undefined;
|
|
495
485
|
const output = (await generator(text, {
|
|
496
486
|
max_new_tokens: maxNewTokens,
|
|
497
487
|
do_sample: false,
|
|
498
488
|
return_full_text: false,
|
|
499
|
-
...(transformers && stop
|
|
500
|
-
? { stopping_criteria: createStopOnTextCriteria(transformers, generator.tokenizer, stop) }
|
|
501
|
-
: {}),
|
|
502
489
|
})) as TextGenerationStringOutput;
|
|
503
|
-
const generated = output[0]?.generated_text ?? "";
|
|
504
|
-
|
|
505
|
-
// including the opening tag it pinned via `prefill`.
|
|
506
|
-
const full = `${prefill ?? ""}${generated}`.trim();
|
|
507
|
-
return full === "" ? null : full;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
function releasePipelines(): void {
|
|
511
|
-
// Intentionally NOT calling `pipeline.dispose()`. transformers.js disposes the
|
|
512
|
-
// underlying onnxruntime InferenceSession, freeing native memory that Bun's
|
|
513
|
-
// worker/NAPI teardown then frees a second time — a double-free that aborts the
|
|
514
|
-
// process on quit ("malloc: pointer being freed was not allocated" /
|
|
515
|
-
// "NAPI FATAL ERROR"). The worker is torn down immediately after `close`, so the
|
|
516
|
-
// OS reclaims the model memory regardless; skipping dispose avoids the crash.
|
|
517
|
-
pipelines.clear();
|
|
490
|
+
const generated = (output[0]?.generated_text ?? "").trim();
|
|
491
|
+
return generated === "" ? null : generated;
|
|
518
492
|
}
|
|
519
493
|
|
|
520
494
|
function enqueueRequest(
|
|
@@ -548,8 +522,6 @@ async function handleQueuedRequest(
|
|
|
548
522
|
request.modelKey,
|
|
549
523
|
request.prompt,
|
|
550
524
|
request.maxTokens,
|
|
551
|
-
request.prefill,
|
|
552
|
-
request.stop,
|
|
553
525
|
);
|
|
554
526
|
transport.send({ type: "completion", id: request.id, text });
|
|
555
527
|
return;
|
|
@@ -567,33 +539,6 @@ export function startTinyTitleWorker(transport: TinyTitleTransport): void {
|
|
|
567
539
|
transport.send({ type: "pong", id: message.id });
|
|
568
540
|
return;
|
|
569
541
|
}
|
|
570
|
-
if (message.type === "close") {
|
|
571
|
-
releasePipelines();
|
|
572
|
-
transport.send({ type: "closed" });
|
|
573
|
-
transport.close();
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
542
|
enqueueRequest(transport, message);
|
|
577
543
|
});
|
|
578
544
|
}
|
|
579
|
-
|
|
580
|
-
if (!parentPort) throw new Error("tiny-title-worker: missing parentPort");
|
|
581
|
-
|
|
582
|
-
const port = parentPort;
|
|
583
|
-
const transport: TinyTitleTransport = {
|
|
584
|
-
send: (message: TinyTitleWorkerOutbound) => port.postMessage(message),
|
|
585
|
-
onMessage: handler => {
|
|
586
|
-
const wrap = (data: unknown): void => handler(data as TinyTitleWorkerInbound);
|
|
587
|
-
port.on("message", wrap);
|
|
588
|
-
return () => port.off("message", wrap);
|
|
589
|
-
},
|
|
590
|
-
close: () => {
|
|
591
|
-
try {
|
|
592
|
-
port.close();
|
|
593
|
-
} catch {
|
|
594
|
-
// Already closed.
|
|
595
|
-
}
|
|
596
|
-
},
|
|
597
|
-
};
|
|
598
|
-
|
|
599
|
-
startTinyTitleWorker(transport);
|