@oh-my-pi/pi-coding-agent 16.5.1 → 16.5.2
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 +65 -0
- package/dist/cli.js +3442 -3408
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
- package/dist/types/eval/backend.d.ts +3 -3
- package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
- package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
- package/dist/types/goals/guided-setup.d.ts +12 -0
- package/dist/types/internal-urls/history-protocol.d.ts +3 -2
- package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/oauth-flow.d.ts +2 -0
- package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
- package/dist/types/modes/components/agent-hub.d.ts +10 -0
- package/dist/types/modes/components/dynamic-border.d.ts +5 -3
- package/dist/types/modes/components/login-dialog.d.ts +2 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/read-tool-group.d.ts +0 -2
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/session/messages.d.ts +15 -0
- package/dist/types/tools/grep.d.ts +0 -2
- package/dist/types/tools/read.d.ts +0 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +136 -49
- package/src/advisor/runtime.ts +16 -34
- package/src/autoresearch/dashboard.ts +2 -2
- package/src/cli/config-cli.ts +15 -3
- package/src/config/settings-schema.ts +10 -0
- package/src/cursor.ts +2 -0
- package/src/discovery/claude-plugins.ts +9 -3
- package/src/discovery/omp-plugins.ts +6 -2
- package/src/discovery/substitute-plugin-root.ts +32 -0
- package/src/eval/__tests__/prelude-agent.test.ts +20 -0
- package/src/eval/backend.ts +3 -3
- package/src/eval/py/__tests__/prelude.test.ts +72 -0
- package/src/eval/py/prelude.py +28 -1
- package/src/exec/bash-executor.ts +30 -43
- package/src/extensibility/extensions/wrapper.ts +18 -18
- package/src/extensibility/plugins/bun-git-cache.ts +91 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
- package/src/extensibility/plugins/manager.ts +7 -7
- package/src/goals/guided-setup.ts +29 -1
- package/src/internal-urls/history-protocol.ts +95 -15
- package/src/internal-urls/registry-helpers.ts +50 -1
- package/src/launch/broker.ts +38 -25
- package/src/mcp/oauth-discovery.ts +20 -1
- package/src/mcp/oauth-flow.ts +3 -1
- package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
- package/src/modes/components/agent-dashboard.ts +2 -2
- package/src/modes/components/agent-hub.ts +15 -2
- package/src/modes/components/agent-transcript-viewer.ts +2 -2
- package/src/modes/components/chat-transcript-builder.ts +4 -3
- package/src/modes/components/dynamic-border.ts +9 -6
- package/src/modes/components/extensions/extension-list.ts +2 -2
- package/src/modes/components/hook-selector.ts +10 -4
- package/src/modes/components/login-dialog.ts +5 -0
- package/src/modes/components/mcp-add-wizard.ts +5 -0
- package/src/modes/components/plan-review-overlay.ts +11 -11
- package/src/modes/components/read-tool-group.ts +1 -8
- package/src/modes/controllers/input-controller.ts +4 -2
- package/src/modes/controllers/mcp-command-controller.ts +6 -7
- package/src/modes/controllers/selector-controller.ts +9 -2
- package/src/modes/controllers/todo-command-controller.ts +18 -14
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/prompt-action-autocomplete.ts +6 -1
- package/src/modes/types.ts +1 -1
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/grep.md +1 -2
- package/src/prompts/tools/read.md +2 -4
- package/src/sdk.ts +28 -31
- package/src/session/agent-session.ts +44 -22
- package/src/session/messages.test.ts +66 -0
- package/src/session/messages.ts +37 -0
- package/src/system-prompt.test.ts +36 -0
- package/src/system-prompt.ts +1 -1
- package/src/tools/browser/registry.ts +17 -3
- package/src/tools/eval.ts +14 -9
- package/src/tools/gh.ts +3 -1
- package/src/tools/grep.ts +5 -45
- package/src/tools/path-utils.ts +7 -1
- package/src/tools/read.ts +23 -74
- package/src/utils/title-generator.ts +10 -6
- package/src/web/search/providers/perplexity-auth.ts +20 -11
- package/src/web/search/providers/perplexity.ts +14 -2
|
@@ -10,7 +10,7 @@ Fields:
|
|
|
10
10
|
- `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}`"rb"` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}`"jl"` persistent Julia kernel{{/if}}.
|
|
11
11
|
- `code` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
|
|
12
12
|
- `title` (optional) — short transcript label (e.g. `"imports"`).
|
|
13
|
-
- `timeout` (optional) — seconds. Raise only for heavy compute or long non-agent tool calls.
|
|
13
|
+
- `timeout` (optional) — seconds; `0` disables the cell timeout. Raise only for heavy compute or long non-agent tool calls.
|
|
14
14
|
- `reset` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
|
|
15
15
|
|
|
16
16
|
{{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}}
|
|
@@ -28,7 +28,7 @@ display(value) → None
|
|
|
28
28
|
print(value, ...) → None
|
|
29
29
|
Text output.
|
|
30
30
|
read(path, offset?=1, limit?=None) → str
|
|
31
|
-
File
|
|
31
|
+
File/resource text; offset/limit = 1-indexed lines. `local://…` works everywhere; Python/JS also accept top-level `read` URI schemes.
|
|
32
32
|
write(path, content) → str
|
|
33
33
|
Write file (creates parents) → resolved path. `local://…` persists across turns/subagents.
|
|
34
34
|
env(key?=None, value?=None) → str | None | dict
|
|
@@ -2,8 +2,7 @@ Greps files using regex.
|
|
|
2
2
|
|
|
3
3
|
<instruction>
|
|
4
4
|
- Supports Rust regex and PCRE2 syntax.
|
|
5
|
-
- `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`).
|
|
6
|
-
- Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
|
|
5
|
+
- `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Append a line selector to one file path (e.g. `src/foo.ts:50-100`); selectors never choose the search root.
|
|
7
6
|
- Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
|
|
8
7
|
</instruction>
|
|
9
8
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via `path
|
|
1
|
+
Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via `path`.
|
|
2
2
|
|
|
3
3
|
<instruction>
|
|
4
4
|
- SHOULD parallelize independent reads.
|
|
@@ -7,8 +7,7 @@ Read files, directories, archives, SQLite, images, documents, internal resources
|
|
|
7
7
|
|
|
8
8
|
## Parameters
|
|
9
9
|
|
|
10
|
-
- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL.
|
|
11
|
-
- `selector` — optional selector without leading `:` (e.g. `"50-200"`, `"raw"`, `"raw:50-100"`, `"conflicts"`). Use when `path` contains literal colons: `{"path":"test:1-2","selector":"1-2"}`.
|
|
10
|
+
- `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL. Append `:<sel>` for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
|
|
12
11
|
|
|
13
12
|
## Selectors
|
|
14
13
|
|
|
@@ -73,6 +72,5 @@ All URI schemes take the same line selectors. `artifact://<id>` recovers spilled
|
|
|
73
72
|
`ssh://host/<absolute-path>` reads a remote text file (UTF-8, ≤1 MiB) or lists a directory one level deep, on a pre-configured SSH host or `~/.ssh/config` alias; `ssh://host/` lists the remote root and bare `ssh://` lists the configured hosts. Files are also writable via `write` and searchable via `search`; a directory only lists (`search` refuses a directory, `write` refuses to overwrite one). A literal `:`, `?`, or `#` in the remote path must be percent-encoded (`%3A`/`%3F`/`%23`) — a trailing `:sel` is read as a line selector, and `?`/`#` start a URL query/fragment. Requires a POSIX login shell (`sh`/`bash`/`zsh`); a Windows host or a non-POSIX shell (fish, csh/tcsh) is rejected — use the `ssh` tool there.
|
|
74
73
|
|
|
75
74
|
<critical>
|
|
76
|
-
- Literal colon filename + selector? Use `selector`, not recursive `path:"file:sel:sel"`.
|
|
77
75
|
- Summary footer names elided ranges? Re-issue ONLY those ranges. NEVER guess `..`/`…` content.
|
|
78
76
|
</critical>
|
package/src/sdk.ts
CHANGED
|
@@ -117,6 +117,7 @@ import {
|
|
|
117
117
|
type CustomMessage,
|
|
118
118
|
convertToLlm,
|
|
119
119
|
LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
|
|
120
|
+
replaceLlmImagesWithText,
|
|
120
121
|
USER_INTERRUPT_LABEL,
|
|
121
122
|
wrapSteeringForModel,
|
|
122
123
|
} from "./session/messages";
|
|
@@ -1826,10 +1827,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1826
1827
|
// to mirror the AsyncJobManager ownership rule.
|
|
1827
1828
|
if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
|
|
1828
1829
|
|
|
1829
|
-
// Add image tools when
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1830
|
+
// Add image tools when generation is enabled and either no explicit tool
|
|
1831
|
+
// whitelist was given or it names `generate_image`. Unlike built-in tools
|
|
1832
|
+
// (filtered in `createTools`), custom tools are force-activated via
|
|
1833
|
+
// `alwaysInclude` below, so an explicit `--no-tools`/whitelist must be
|
|
1834
|
+
// honored here or image-gen would leak past every filter (issue #5305).
|
|
1835
|
+
const imageGenRequested = !options.toolNames || options.toolNames.includes("generate_image");
|
|
1836
|
+
if (settings.get("generate_image.enabled") && imageGenRequested) {
|
|
1837
|
+
const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
|
|
1838
|
+
if (imageGenTools.length > 0) {
|
|
1839
|
+
customTools.push(...(imageGenTools as unknown as CustomTool[]));
|
|
1840
|
+
}
|
|
1833
1841
|
}
|
|
1834
1842
|
|
|
1835
1843
|
if (settings.get("speechgen.enabled")) {
|
|
@@ -2648,36 +2656,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
2648
2656
|
|
|
2649
2657
|
const slashCommands = await slashCommandsPromise;
|
|
2650
2658
|
|
|
2651
|
-
//
|
|
2659
|
+
// Keep image blocks off the wire when they'd be rejected: either the user
|
|
2660
|
+
// disabled images (`images.blockImages`) or the active model has no vision
|
|
2661
|
+
// support. The latter covers switching from a vision model to a text-only
|
|
2662
|
+
// one mid-session — historical image blocks would otherwise be replayed to
|
|
2663
|
+
// a provider that 400s on them (#5400). Read both dynamically so a `/model`
|
|
2664
|
+
// switch or setting change takes effect on the next turn.
|
|
2652
2665
|
const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
|
|
2653
2666
|
const converted = convertToLlm(messages);
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
return converted;
|
|
2667
|
+
if (settings.get("images.blockImages")) {
|
|
2668
|
+
return replaceLlmImagesWithText(converted, "Image reading is disabled.");
|
|
2657
2669
|
}
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
.map(c =>
|
|
2667
|
-
c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c,
|
|
2668
|
-
)
|
|
2669
|
-
.filter((c, i, arr) => {
|
|
2670
|
-
// Dedupe consecutive "Image reading is disabled." texts
|
|
2671
|
-
if (!(c.type === "text" && c.text === "Image reading is disabled." && i > 0)) return true;
|
|
2672
|
-
const prev = arr[i - 1];
|
|
2673
|
-
return !(prev.type === "text" && prev.text === "Image reading is disabled.");
|
|
2674
|
-
});
|
|
2675
|
-
return { ...msg, content: filteredContent };
|
|
2676
|
-
}
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
|
-
return msg;
|
|
2680
|
-
});
|
|
2670
|
+
const activeModel = agent?.state.model ?? model;
|
|
2671
|
+
if (activeModel && !activeModel.input.includes("image")) {
|
|
2672
|
+
return replaceLlmImagesWithText(
|
|
2673
|
+
converted,
|
|
2674
|
+
"[image omitted: the active model does not support image input]",
|
|
2675
|
+
);
|
|
2676
|
+
}
|
|
2677
|
+
return converted;
|
|
2681
2678
|
};
|
|
2682
2679
|
|
|
2683
2680
|
// Final convertToLlm: live provider replay drops API-level refusal errors,
|
|
@@ -10431,24 +10431,37 @@ export class AgentSession {
|
|
|
10431
10431
|
|
|
10432
10432
|
// Strategy honored on manual /compact too. Custom instructions (public
|
|
10433
10433
|
// user focus OR internal plan-mode guidance) imply a directed LLM
|
|
10434
|
-
// summary; a text-only model cannot read snapcompact frames.
|
|
10435
|
-
// snapcompact itself was requested, fail locally instead of silently
|
|
10436
|
-
// converting the "no LLM call" path into a provider-backed summary.
|
|
10434
|
+
// summary; a text-only model cannot read snapcompact frames.
|
|
10437
10435
|
const wantsSnapcompact =
|
|
10438
10436
|
compactionPrep.kind !== "fromHook" &&
|
|
10439
10437
|
effectiveSettings.strategy === "snapcompact" &&
|
|
10440
10438
|
!customInstructions &&
|
|
10441
10439
|
!options?.internalGuidance;
|
|
10442
|
-
|
|
10440
|
+
// `/compact snapcompact` is an explicit no-LLM archive request: honor
|
|
10441
|
+
// its contract by failing locally rather than silently shipping the
|
|
10442
|
+
// transcript to a provider. The default-configured snapcompact
|
|
10443
|
+
// strategy, in contrast, falls back to LLM compaction (mirroring the
|
|
10444
|
+
// auto-compaction path) so a routine /compact still completes on a
|
|
10445
|
+
// text-only model (issue #5064).
|
|
10446
|
+
const explicitSnapcompact = compactMode?.name === "snapcompact";
|
|
10447
|
+
let snapcompactReady = wantsSnapcompact;
|
|
10443
10448
|
const snapcompactShapeSetting = this.settings.get("snapcompact.shape");
|
|
10444
10449
|
let snapcompactShape: snapcompact.Shape | undefined;
|
|
10445
10450
|
if (wantsSnapcompact && !this.model.input.includes("image")) {
|
|
10451
|
+
if (explicitSnapcompact) {
|
|
10452
|
+
this.emitNotice(
|
|
10453
|
+
"warning",
|
|
10454
|
+
`snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
|
|
10455
|
+
"compaction",
|
|
10456
|
+
);
|
|
10457
|
+
throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
|
|
10458
|
+
}
|
|
10446
10459
|
this.emitNotice(
|
|
10447
10460
|
"warning",
|
|
10448
|
-
`snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
|
|
10461
|
+
`snapcompact needs a vision-capable model (${this.model.id} is text-only); falling back to LLM compaction`,
|
|
10449
10462
|
"compaction",
|
|
10450
10463
|
);
|
|
10451
|
-
|
|
10464
|
+
snapcompactReady = false;
|
|
10452
10465
|
} else if (snapcompactReady) {
|
|
10453
10466
|
const text = snapcompact.serializeConversation(
|
|
10454
10467
|
convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
|
|
@@ -14037,19 +14050,23 @@ export class AgentSession {
|
|
|
14037
14050
|
}
|
|
14038
14051
|
|
|
14039
14052
|
/**
|
|
14040
|
-
* Retry an empty, reason-less provider abort: a turn
|
|
14041
|
-
*
|
|
14042
|
-
*
|
|
14043
|
-
*
|
|
14044
|
-
* session
|
|
14045
|
-
*
|
|
14046
|
-
*
|
|
14047
|
-
* `#
|
|
14048
|
-
*
|
|
14053
|
+
* Retry an empty, reason-less provider abort: a turn with no content that
|
|
14054
|
+
* carries the generic sentinel (bare `abort()`), whether the provider
|
|
14055
|
+
* finalized it as `stopReason: "aborted"` or leaked it as `stopReason:
|
|
14056
|
+
* "error"` (a stalled/dropped stream reported as an error rather than an
|
|
14057
|
+
* abort — issue #5375). Only fires while the session is neither aborting nor
|
|
14058
|
+
* tearing down. A user/lifecycle abort (`#abortInProgress`), a dispose-driven
|
|
14059
|
+
* abort (`#isDisposed`), or a session-induced streaming-edit guard abort
|
|
14060
|
+
* (`#streamingEditAbortTriggered` — auto-generated-file guard or failed-patch
|
|
14061
|
+
* preview) is deliberate and MUST settle the turn instead: routing it through
|
|
14062
|
+
* retry would orphan `#retryPromise` on a continuation the guard skips
|
|
14063
|
+
* (hanging the in-flight `prompt()`) or silently undo the guard's intended
|
|
14064
|
+
* abort. Deliberate user interrupts (`UserInterrupt`) and silent aborts carry
|
|
14065
|
+
* their own marker, not the generic sentinel, so they never match here.
|
|
14049
14066
|
*/
|
|
14050
14067
|
#isRetryableReasonlessAbort(message: AssistantMessage): boolean {
|
|
14051
14068
|
if (
|
|
14052
|
-
message.stopReason !== "aborted" ||
|
|
14069
|
+
(message.stopReason !== "aborted" && message.stopReason !== "error") ||
|
|
14053
14070
|
message.content.length !== 0 ||
|
|
14054
14071
|
this.#abortInProgress ||
|
|
14055
14072
|
this.#isDisposed ||
|
|
@@ -14059,7 +14076,7 @@ export class AgentSession {
|
|
|
14059
14076
|
}
|
|
14060
14077
|
|
|
14061
14078
|
const id = this.#classifyRetryMessage(message);
|
|
14062
|
-
if (AIError.is(id, AIError.Flag.Abort)) return true;
|
|
14079
|
+
if (message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) return true;
|
|
14063
14080
|
if (!this.#isGenericAbortSentinel(message)) return false;
|
|
14064
14081
|
|
|
14065
14082
|
message.errorId = AIError.create(AIError.Flag.Abort);
|
|
@@ -16215,7 +16232,7 @@ export class AgentSession {
|
|
|
16215
16232
|
// User message: leaf = parent (null if root), text goes to editor
|
|
16216
16233
|
newLeafId = targetEntry.parentId;
|
|
16217
16234
|
editorText = this.#extractUserMessageText(targetEntry.message.content);
|
|
16218
|
-
} else if (targetEntry.type === "custom_message") {
|
|
16235
|
+
} else if (targetEntry.type === "custom_message" && targetEntry.customType !== SKILL_PROMPT_MESSAGE_TYPE) {
|
|
16219
16236
|
// Custom message: leaf = parent (null if root), text goes to editor
|
|
16220
16237
|
newLeafId = targetEntry.parentId;
|
|
16221
16238
|
editorText =
|
|
@@ -16226,7 +16243,10 @@ export class AgentSession {
|
|
|
16226
16243
|
.map(c => c.text)
|
|
16227
16244
|
.join("");
|
|
16228
16245
|
} else {
|
|
16229
|
-
// Non-user message
|
|
16246
|
+
// Non-user message (or a user-invoked skill-prompt injection): land the
|
|
16247
|
+
// leaf on the selected node so it stays on the active branch. Skill
|
|
16248
|
+
// prompts are custom_message entries but must not be re-editable — their
|
|
16249
|
+
// content is a large expanded body, not a user turn (issue #5374).
|
|
16230
16250
|
newLeafId = targetId;
|
|
16231
16251
|
}
|
|
16232
16252
|
|
|
@@ -16579,10 +16599,12 @@ export class AgentSession {
|
|
|
16579
16599
|
}
|
|
16580
16600
|
|
|
16581
16601
|
#ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
|
|
16582
|
-
|
|
16583
|
-
|
|
16602
|
+
const provider = model?.provider;
|
|
16603
|
+
if (!provider) return;
|
|
16604
|
+
// No-op for providers whose usage strategy lacks a header parser.
|
|
16605
|
+
this.#modelRegistry.authStorage.ingestUsageHeaders(provider, response.headers, {
|
|
16584
16606
|
sessionId: this.agent.sessionId,
|
|
16585
|
-
baseUrl: this.#modelRegistry.getProviderBaseUrl?.(
|
|
16607
|
+
baseUrl: this.#modelRegistry.getProviderBaseUrl?.(provider),
|
|
16586
16608
|
});
|
|
16587
16609
|
}
|
|
16588
16610
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CustomMessage,
|
|
6
6
|
convertToLlm,
|
|
7
7
|
INTERRUPTED_THINKING_MESSAGE_TYPE,
|
|
8
|
+
replaceLlmImagesWithText,
|
|
8
9
|
SKILL_PROMPT_MESSAGE_TYPE,
|
|
9
10
|
type SkillPromptDetails,
|
|
10
11
|
} from "./messages";
|
|
@@ -123,3 +124,68 @@ describe("convertToLlm", () => {
|
|
|
123
124
|
]);
|
|
124
125
|
});
|
|
125
126
|
});
|
|
127
|
+
|
|
128
|
+
describe("replaceLlmImagesWithText", () => {
|
|
129
|
+
it("replaces image blocks in user, developer, and tool-result messages with the placeholder", () => {
|
|
130
|
+
const converted = convertToLlm([
|
|
131
|
+
{
|
|
132
|
+
role: "user",
|
|
133
|
+
content: [
|
|
134
|
+
{ type: "text", text: "look" },
|
|
135
|
+
{ type: "image", data: "aaaa", mimeType: "image/png" },
|
|
136
|
+
],
|
|
137
|
+
attribution: "user",
|
|
138
|
+
timestamp: 1,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
role: "toolResult",
|
|
142
|
+
toolCallId: "c1",
|
|
143
|
+
toolName: "inspect_image",
|
|
144
|
+
content: [{ type: "image", data: "bbbb", mimeType: "image/png" }],
|
|
145
|
+
isError: false,
|
|
146
|
+
timestamp: 2,
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
|
|
151
|
+
|
|
152
|
+
expect(scrubbed).not.toBe(converted);
|
|
153
|
+
const types = scrubbed.flatMap(m => (Array.isArray(m.content) ? m.content.map(b => b.type) : []));
|
|
154
|
+
expect(types).not.toContain("image");
|
|
155
|
+
const user = scrubbed.find(m => m.role === "user");
|
|
156
|
+
expect(Array.isArray(user?.content) && user.content.map(b => (b.type === "text" ? b.text : b.type))).toEqual([
|
|
157
|
+
"look",
|
|
158
|
+
"[image omitted]",
|
|
159
|
+
]);
|
|
160
|
+
const toolResult = scrubbed.find(m => m.role === "toolResult");
|
|
161
|
+
expect(Array.isArray(toolResult?.content) && toolResult.content).toEqual([
|
|
162
|
+
{ type: "text", text: "[image omitted]" },
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("collapses consecutive image blocks into a single placeholder", () => {
|
|
167
|
+
const converted = convertToLlm([
|
|
168
|
+
{
|
|
169
|
+
role: "user",
|
|
170
|
+
content: [
|
|
171
|
+
{ type: "image", data: "aaaa", mimeType: "image/png" },
|
|
172
|
+
{ type: "image", data: "bbbb", mimeType: "image/png" },
|
|
173
|
+
],
|
|
174
|
+
attribution: "user",
|
|
175
|
+
timestamp: 1,
|
|
176
|
+
},
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
|
|
180
|
+
const user = scrubbed.find(m => m.role === "user");
|
|
181
|
+
expect(Array.isArray(user?.content) && user.content).toEqual([{ type: "text", text: "[image omitted]" }]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns the same array reference when there are no image blocks", () => {
|
|
185
|
+
const converted = convertToLlm([
|
|
186
|
+
{ role: "user", content: [{ type: "text", text: "hi" }], attribution: "user", timestamp: 1 },
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
expect(replaceLlmImagesWithText(converted, "[image omitted]")).toBe(converted);
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/session/messages.ts
CHANGED
|
@@ -512,6 +512,43 @@ export function stripImagesFromMessage(message: AgentMessage): number {
|
|
|
512
512
|
}
|
|
513
513
|
}
|
|
514
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Replace every `ImageContent` block in already-converted LLM {@link Message}s
|
|
517
|
+
* with a text placeholder, returning a new array only when something changed.
|
|
518
|
+
*
|
|
519
|
+
* Unlike {@link stripImagesFromMessage} (which mutates persisted `AgentMessage`s
|
|
520
|
+
* in place), this operates on the ephemeral provider-request view produced by
|
|
521
|
+
* {@link convertToLlm}, so history on disk keeps its images while the outbound
|
|
522
|
+
* request is scrubbed. Used to keep image blocks off the wire when the active
|
|
523
|
+
* model has no vision support (or `images.blockImages` is set) — e.g. after
|
|
524
|
+
* switching from a vision model to a text-only one mid-session (#5400).
|
|
525
|
+
*
|
|
526
|
+
* Consecutive placeholder texts collapse into one so a message that was nothing
|
|
527
|
+
* but images does not balloon into a run of identical notes.
|
|
528
|
+
*/
|
|
529
|
+
export function replaceLlmImagesWithText(messages: Message[], placeholder: string): Message[] {
|
|
530
|
+
let out: Message[] | undefined;
|
|
531
|
+
for (let i = 0; i < messages.length; i++) {
|
|
532
|
+
const msg = messages[i];
|
|
533
|
+
if (msg.role !== "user" && msg.role !== "developer" && msg.role !== "toolResult") continue;
|
|
534
|
+
const content = msg.content;
|
|
535
|
+
if (!Array.isArray(content) || !content.some(part => part.type === "image")) continue;
|
|
536
|
+
const replaced: (TextContent | ImageContent)[] = [];
|
|
537
|
+
for (const part of content) {
|
|
538
|
+
if (part.type !== "image") {
|
|
539
|
+
replaced.push(part);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
const prev = replaced[replaced.length - 1];
|
|
543
|
+
if (prev?.type === "text" && prev.text === placeholder) continue;
|
|
544
|
+
replaced.push({ type: "text", text: placeholder });
|
|
545
|
+
}
|
|
546
|
+
if (out === undefined) out = messages.slice();
|
|
547
|
+
out[i] = { ...msg, content: replaced } as Message;
|
|
548
|
+
}
|
|
549
|
+
return out ?? messages;
|
|
550
|
+
}
|
|
551
|
+
|
|
515
552
|
/**
|
|
516
553
|
* Message type for bash executions via the ! command.
|
|
517
554
|
*/
|
|
@@ -198,3 +198,39 @@ describe.skipIf(process.platform !== "linux")("system prompt CPU model", () => {
|
|
|
198
198
|
}
|
|
199
199
|
});
|
|
200
200
|
});
|
|
201
|
+
|
|
202
|
+
describe("non-Linux system prompt CPU model", () => {
|
|
203
|
+
it("includes the model returned by os.cpus", async () => {
|
|
204
|
+
const originalPlatform = process.platform;
|
|
205
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
206
|
+
const cpus = spyOn(os, "cpus").mockImplementation(() => [
|
|
207
|
+
{
|
|
208
|
+
model: "Synthetic Non-Linux CPU",
|
|
209
|
+
speed: 0,
|
|
210
|
+
times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 },
|
|
211
|
+
},
|
|
212
|
+
]);
|
|
213
|
+
try {
|
|
214
|
+
const systemPrompt = await buildSystemPrompt({
|
|
215
|
+
resolvedCustomPrompt: "Base prompt",
|
|
216
|
+
contextFiles: [],
|
|
217
|
+
skills: [],
|
|
218
|
+
rules: [],
|
|
219
|
+
workspaceTree: {
|
|
220
|
+
rootPath: import.meta.dir,
|
|
221
|
+
rendered: "",
|
|
222
|
+
truncated: false,
|
|
223
|
+
totalLines: 0,
|
|
224
|
+
agentsMdFiles: [],
|
|
225
|
+
},
|
|
226
|
+
activeRepoContext: null,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(cpus).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(systemPrompt.systemPrompt.join("\n")).toContain("- CPU: Synthetic Non-Linux CPU");
|
|
231
|
+
} finally {
|
|
232
|
+
cpus.mockRestore();
|
|
233
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/system-prompt.ts
CHANGED
|
@@ -254,7 +254,7 @@ async function getCachedGpu(): Promise<string | undefined> {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
async function getCpuModel(): Promise<string | undefined> {
|
|
257
|
-
if (process.platform !== "linux") return
|
|
257
|
+
if (process.platform !== "linux") return os.cpus()[0]?.model;
|
|
258
258
|
try {
|
|
259
259
|
const cpuInfo = await Bun.file("/proc/cpuinfo").text();
|
|
260
260
|
const match = /^model name\s*:\s*(.+)$/m.exec(cpuInfo);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
-
import { logger } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { logger, withTimeout } from "@oh-my-pi/pi-utils";
|
|
3
3
|
import type { Subprocess } from "bun";
|
|
4
4
|
import type { Browser, CDPSession } from "puppeteer-core";
|
|
5
5
|
import { ToolAbortError, ToolError } from "../tool-errors";
|
|
@@ -17,6 +17,13 @@ export type BrowserKind = PuppeteerBrowserKind | CmuxKind;
|
|
|
17
17
|
|
|
18
18
|
export type BrowserKindTag = BrowserKind["kind"];
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Upper bound on `browser.close()` for headless Chromium. Puppeteer waits for
|
|
22
|
+
* the process to fully exit; a wedged Chromium would otherwise hang cleanup
|
|
23
|
+
* forever (issue #5260), so we cap the wait and force-kill on timeout.
|
|
24
|
+
*/
|
|
25
|
+
const HEADLESS_CLOSE_TIMEOUT_MS = 5_000;
|
|
26
|
+
|
|
20
27
|
interface BrowserHandleCommon {
|
|
21
28
|
key: string;
|
|
22
29
|
kind: BrowserKind;
|
|
@@ -232,10 +239,17 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
|
|
|
232
239
|
}
|
|
233
240
|
if (handle.kind.kind === "headless") {
|
|
234
241
|
if (handle.browser.connected) {
|
|
242
|
+
// Puppeteer's `browser.close()` resolves only once the Chromium
|
|
243
|
+
// process fully exits. A wedged Chromium (a known Windows failure
|
|
244
|
+
// mode) leaves this await pending forever, freezing `releaseTab` in
|
|
245
|
+
// the "Closing tab" phase (issue #5260). Bound it, then SIGKILL the
|
|
246
|
+
// process tree so cleanup always completes.
|
|
247
|
+
const proc = handle.browser.process();
|
|
235
248
|
try {
|
|
236
|
-
await handle.browser.close();
|
|
249
|
+
await withTimeout(handle.browser.close(), HEADLESS_CLOSE_TIMEOUT_MS, "Timed out closing headless browser");
|
|
237
250
|
} catch (err) {
|
|
238
|
-
logger.debug("Failed to close headless browser", { error: (err as Error).message });
|
|
251
|
+
logger.debug("Failed to close headless browser; force-killing", { error: (err as Error).message });
|
|
252
|
+
if (proc?.pid !== undefined) await gracefulKillTreeOnce(proc.pid).catch(() => undefined);
|
|
239
253
|
}
|
|
240
254
|
}
|
|
241
255
|
return;
|
package/src/tools/eval.ts
CHANGED
|
@@ -85,7 +85,7 @@ function enabledEvalLanguages(backends: EvalBackendsAllowance): EvalLanguageToke
|
|
|
85
85
|
|
|
86
86
|
const evalCellCommonFields = {
|
|
87
87
|
"title?": type("string").describe('short label shown in transcript (e.g. "imports", "load config")'),
|
|
88
|
-
"timeout?": type("number").describe("timeout for this eval call in seconds"),
|
|
88
|
+
"timeout?": type("number").describe("timeout for this eval call in seconds; 0 disables the cell timeout"),
|
|
89
89
|
"reset?": type("boolean").describe("wipe this language's kernel before running. Other languages are untouched."),
|
|
90
90
|
};
|
|
91
91
|
|
|
@@ -538,11 +538,16 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
538
538
|
// ordinary tool calls all count against the budget. The watchdog drives
|
|
539
539
|
// `combinedSignal`; we pass no wall-clock deadline downstream so the
|
|
540
540
|
// backends never arm a competing fixed timer.
|
|
541
|
-
const idleTimeoutMs = timeoutSecondsFromMs(cell.timeoutMs) * 1000;
|
|
542
|
-
const idle = new IdleTimeout(idleTimeoutMs);
|
|
543
|
-
const combinedSignal =
|
|
544
|
-
|
|
545
|
-
|
|
541
|
+
const idleTimeoutMs = cell.timeoutMs === 0 ? undefined : timeoutSecondsFromMs(cell.timeoutMs) * 1000;
|
|
542
|
+
const idle = idleTimeoutMs === undefined ? undefined : new IdleTimeout(idleTimeoutMs);
|
|
543
|
+
const combinedSignal =
|
|
544
|
+
signal && idle
|
|
545
|
+
? AbortSignal.any([signal, idle.signal, sessionAbortController.signal])
|
|
546
|
+
: signal
|
|
547
|
+
? AbortSignal.any([signal, sessionAbortController.signal])
|
|
548
|
+
: idle
|
|
549
|
+
? AbortSignal.any([idle.signal, sessionAbortController.signal])
|
|
550
|
+
: sessionAbortController.signal;
|
|
546
551
|
|
|
547
552
|
const cellResult = cellResults[i];
|
|
548
553
|
cellResult.status = "running";
|
|
@@ -570,11 +575,11 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
570
575
|
},
|
|
571
576
|
onStatus: event => {
|
|
572
577
|
if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
|
|
573
|
-
idle
|
|
578
|
+
idle?.pause();
|
|
574
579
|
return;
|
|
575
580
|
}
|
|
576
581
|
if (event.op === EVAL_TIMEOUT_RESUME_OP) {
|
|
577
|
-
idle
|
|
582
|
+
idle?.resume();
|
|
578
583
|
return;
|
|
579
584
|
}
|
|
580
585
|
cellResult.statusEvents ??= [];
|
|
@@ -583,7 +588,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
|
|
|
583
588
|
},
|
|
584
589
|
});
|
|
585
590
|
} finally {
|
|
586
|
-
idle
|
|
591
|
+
idle?.dispose();
|
|
587
592
|
activeLiveCell = undefined;
|
|
588
593
|
}
|
|
589
594
|
const durationMs = Date.now() - startTime;
|
package/src/tools/gh.ts
CHANGED
|
@@ -3419,7 +3419,9 @@ async function executeSearchCode(
|
|
|
3419
3419
|
signal: AbortSignal | undefined,
|
|
3420
3420
|
): Promise<AgentToolResult<GhToolDetails>> {
|
|
3421
3421
|
const query = requireNonEmpty(params.query, "query");
|
|
3422
|
-
|
|
3422
|
+
const since = normalizeOptionalString(params.since);
|
|
3423
|
+
const until = normalizeOptionalString(params.until);
|
|
3424
|
+
if (since !== undefined || until !== undefined) {
|
|
3423
3425
|
throw new ToolError("search_code does not support since/until; GitHub code search has no date qualifier.");
|
|
3424
3426
|
}
|
|
3425
3427
|
const limit = resolveSearchLimit(params.limit);
|