@oh-my-pi/pi-coding-agent 17.0.9 → 17.1.1
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 +78 -0
- package/README.md +1 -1
- package/dist/cli.js +4685 -4614
- package/dist/types/async/job-manager.d.ts +37 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/bench-cli.d.ts +41 -0
- package/dist/types/commands/bench.d.ts +15 -3
- package/dist/types/commands/launch.d.ts +4 -0
- package/dist/types/config/model-registry.d.ts +3 -1
- package/dist/types/config/models-config-schema.d.ts +18 -6
- package/dist/types/config/models-config.d.ts +14 -5
- package/dist/types/config/provider-globals.d.ts +2 -2
- package/dist/types/config/service-tier.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +374 -47
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/dap/client.d.ts +20 -0
- package/dist/types/exec/bash-executor.d.ts +38 -0
- package/dist/types/exec/direnv.d.ts +33 -0
- package/dist/types/export/html/args.d.ts +15 -0
- package/dist/types/export/html/index.d.ts +11 -26
- package/dist/types/export/html/web-palette.d.ts +55 -116
- package/dist/types/extensibility/extensions/loader.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +17 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +13 -0
- package/dist/types/live/attestation.d.ts +2 -0
- package/dist/types/live/controller.d.ts +48 -0
- package/dist/types/live/protocol.d.ts +85 -0
- package/dist/types/live/protocol.test.d.ts +1 -0
- package/dist/types/live/transport.d.ts +35 -0
- package/dist/types/live/visualizer.d.ts +30 -0
- package/dist/types/lsp/client.d.ts +16 -1
- package/dist/types/mcp/config-writer.test.d.ts +1 -0
- package/dist/types/mcp/oauth-discovery.d.ts +2 -0
- package/dist/types/mcp/smithery-auth.d.ts +1 -1
- package/dist/types/mcp/smithery-auth.test.d.ts +1 -0
- package/dist/types/memory-backend/tool-names.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -0
- package/dist/types/modes/components/custom-message.d.ts +1 -1
- package/dist/types/modes/components/login-dialog.d.ts +2 -2
- package/dist/types/modes/components/message-frame.d.ts +8 -4
- package/dist/types/modes/components/plan-review-overlay.d.ts +26 -0
- package/dist/types/modes/components/session-account-selector.d.ts +11 -0
- package/dist/types/modes/components/settings-defs.d.ts +7 -1
- package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +9 -1
- package/dist/types/modes/controllers/command-controller.d.ts +1 -1
- package/dist/types/modes/controllers/event-controller.d.ts +6 -1
- package/dist/types/modes/controllers/live-command-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +6 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/modes/types.d.ts +4 -1
- package/dist/types/modes/utils/context-usage.d.ts +12 -2
- package/dist/types/plan-mode/plan-files.d.ts +10 -0
- package/dist/types/registry/agent-lifecycle.d.ts +21 -9
- package/dist/types/registry/agent-registry.d.ts +11 -4
- package/dist/types/sdk.d.ts +15 -1
- package/dist/types/secrets/index.d.ts +15 -2
- package/dist/types/secrets/obfuscator.d.ts +113 -15
- package/dist/types/session/acp-permission-gate.d.ts +19 -0
- package/dist/types/session/agent-session-events.d.ts +84 -0
- package/dist/types/session/agent-session-types.d.ts +321 -0
- package/dist/types/session/agent-session.d.ts +135 -753
- package/dist/types/session/async-job-delivery.d.ts +28 -0
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/bash-runner.d.ts +75 -0
- package/dist/types/session/blob-store.d.ts +11 -1
- package/dist/types/session/blob-store.test.d.ts +1 -0
- package/dist/types/session/checkpoint-entries.d.ts +28 -0
- package/dist/types/session/eval-runner.d.ts +51 -0
- package/dist/types/session/irc-bridge.d.ts +46 -0
- package/dist/types/session/messages.d.ts +24 -0
- package/dist/types/session/model-controls.d.ts +180 -0
- package/dist/types/session/prewalk.d.ts +46 -0
- package/dist/types/session/queued-messages.d.ts +22 -0
- package/dist/types/session/redis-session-storage.d.ts +1 -0
- package/dist/types/session/retry-fallback-chains.d.ts +74 -0
- package/dist/types/session/role-models.d.ts +15 -0
- package/dist/types/session/session-advisors.d.ts +228 -0
- package/dist/types/session/session-entries.d.ts +8 -0
- package/dist/types/session/session-handoff.d.ts +70 -0
- package/dist/types/session/session-history-format.d.ts +6 -1
- package/dist/types/session/session-maintenance.d.ts +246 -0
- package/dist/types/session/session-manager.d.ts +41 -0
- package/dist/types/session/session-memory.d.ts +56 -0
- package/dist/types/session/session-provider-boundary.d.ts +62 -0
- package/dist/types/session/session-stats.d.ts +48 -0
- package/dist/types/session/session-tools.d.ts +158 -0
- package/dist/types/session/session-workspace.d.ts +32 -0
- package/dist/types/session/stream-guards.d.ts +44 -0
- package/dist/types/session/streaming-output.d.ts +8 -0
- package/dist/types/session/todo-tracker.d.ts +56 -0
- package/dist/types/session/ttsr-coordinator.d.ts +51 -0
- package/dist/types/session/turn-recovery.d.ts +225 -0
- package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
- package/dist/types/stt/index.d.ts +0 -2
- package/dist/types/stt/stt-controller.d.ts +7 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +4 -1
- package/dist/types/task/output-manager.d.ts +3 -1
- package/dist/types/tiny/title-client.d.ts +10 -0
- package/dist/types/tools/approval.d.ts +1 -0
- package/dist/types/tools/browser/launch.d.ts +5 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/computer/protocol.d.ts +43 -0
- package/dist/types/tools/computer/supervisor.d.ts +32 -0
- package/dist/types/tools/computer/worker-entry.d.ts +1 -0
- package/dist/types/tools/computer/worker.d.ts +15 -0
- package/dist/types/tools/computer-renderer.d.ts +22 -0
- package/dist/types/tools/computer.d.ts +71 -0
- package/dist/types/tools/context.d.ts +2 -0
- package/dist/types/tools/default-renderer.d.ts +21 -0
- package/dist/types/tools/image-gen.d.ts +4 -4
- package/dist/types/tools/image-providers.d.ts +38 -0
- package/dist/types/tools/index.d.ts +7 -0
- package/dist/types/tools/path-utils.d.ts +8 -0
- package/dist/types/tools/todo.d.ts +10 -4
- package/dist/types/tools/xdev.d.ts +5 -1
- package/dist/types/tts/streaming-player.d.ts +10 -29
- package/dist/types/tts/vocalizer.d.ts +5 -0
- package/dist/types/utils/jj.d.ts +29 -0
- package/dist/types/utils/lang-from-path.d.ts +1 -0
- package/dist/types/utils/title-generator.d.ts +30 -0
- package/dist/types/utils/tools-manager.d.ts +1 -2
- package/dist/types/vibe/runtime.d.ts +36 -8
- package/dist/types/web/search/index.d.ts +1 -1
- package/dist/types/web/search/provider.d.ts +15 -5
- package/dist/types/web/search/types.d.ts +94 -0
- package/package.json +12 -12
- package/scripts/generate-share-viewer.ts +4 -6
- package/src/advisor/__tests__/advisor.test.ts +643 -0
- package/src/advisor/runtime.ts +144 -43
- package/src/async/job-manager.ts +90 -2
- package/src/cli/args.ts +3 -0
- package/src/cli/auth-gateway-cli.ts +18 -3
- package/src/cli/bench-cli.ts +395 -29
- package/src/cli/flag-tables.ts +3 -0
- package/src/cli/gc-cli.ts +2 -2
- package/src/cli/setup-cli.ts +2 -14
- package/src/cli/usage-cli.ts +10 -1
- package/src/cli.ts +8 -0
- package/src/collab/host.ts +3 -2
- package/src/commands/bench.ts +18 -4
- package/src/commands/launch.ts +4 -0
- package/src/config/model-registry.ts +16 -2
- package/src/config/models-config-schema.ts +15 -4
- package/src/config/provider-globals.ts +9 -9
- package/src/config/service-tier.ts +26 -1
- package/src/config/settings-schema.ts +270 -51
- package/src/config/settings.ts +81 -2
- package/src/dap/client.ts +37 -4
- package/src/edit/index.ts +49 -12
- package/src/eval/executor-base.ts +1 -0
- package/src/eval/js/executor.ts +2 -0
- package/src/eval/py/executor.ts +103 -33
- package/src/exec/bash-executor.ts +90 -6
- package/src/exec/direnv.ts +145 -0
- package/src/export/html/args.ts +20 -0
- package/src/export/html/index.ts +45 -47
- package/src/export/html/template.css +19 -1
- package/src/export/html/template.html +6 -0
- package/src/export/html/template.js +21 -0
- package/src/export/html/tool-views.generated.js +31 -31
- package/src/export/html/web-palette.ts +116 -132
- package/src/export/share.ts +249 -49
- package/src/extensibility/extensions/loader.ts +29 -1
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +33 -2
- package/src/extensibility/extensions/wrapper.ts +68 -12
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +19 -1
- package/src/hindsight/state.ts +64 -6
- package/src/internal-urls/local-protocol.ts +2 -1
- package/src/internal-urls/memory-protocol.ts +2 -2
- package/src/internal-urls/skill-protocol.ts +2 -2
- package/src/internal-urls/ssh-protocol.ts +2 -1
- package/src/internal-urls/vault-protocol.ts +2 -1
- package/src/live/attestation.ts +91 -0
- package/src/live/controller.ts +517 -0
- package/src/live/prompts/agent-final-message.md +3 -0
- package/src/live/prompts/live-instructions.md +23 -0
- package/src/live/protocol.test.ts +140 -0
- package/src/live/protocol.ts +233 -0
- package/src/live/transport.ts +422 -0
- package/src/live/visualizer.ts +214 -0
- package/src/lsp/client.ts +29 -9
- package/src/lsp/index.ts +19 -4
- package/src/main.ts +8 -1
- package/src/mcp/config-writer.test.ts +43 -0
- package/src/mcp/config-writer.ts +109 -82
- package/src/mcp/oauth-discovery.ts +10 -2
- package/src/mcp/smithery-auth.test.ts +29 -0
- package/src/mcp/smithery-auth.ts +3 -2
- package/src/memories/index.ts +40 -20
- package/src/memory-backend/tool-names.ts +2 -0
- package/src/mnemopi/backend.ts +24 -7
- package/src/modes/acp/acp-agent.ts +56 -10
- package/src/modes/acp/acp-event-mapper.ts +8 -1
- package/src/modes/components/agent-hub.ts +1 -1
- package/src/modes/components/assistant-message.ts +7 -2
- package/src/modes/components/custom-message.ts +4 -1
- package/src/modes/components/login-dialog.ts +14 -4
- package/src/modes/components/message-frame.ts +14 -8
- package/src/modes/components/plan-review-overlay.ts +350 -35
- package/src/modes/components/session-account-selector.ts +62 -0
- package/src/modes/components/settings-defs.ts +23 -1
- package/src/modes/components/settings-selector.ts +169 -1
- package/src/modes/components/status-line/component.jj-cache.test.ts +229 -0
- package/src/modes/components/status-line/component.ts +123 -2
- package/src/modes/components/tool-execution.ts +28 -112
- package/src/modes/controllers/command-controller.ts +56 -15
- package/src/modes/controllers/event-controller.ts +111 -16
- package/src/modes/controllers/extension-ui-controller.test.ts +1 -0
- package/src/modes/controllers/extension-ui-controller.ts +20 -4
- package/src/modes/controllers/input-controller.ts +47 -39
- package/src/modes/controllers/live-command-controller.ts +255 -0
- package/src/modes/controllers/mcp-command-controller.ts +10 -1
- package/src/modes/controllers/omfg-controller.ts +44 -40
- package/src/modes/controllers/selector-controller.ts +80 -10
- package/src/modes/controllers/session-focus-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +176 -38
- package/src/modes/runtime-init.ts +2 -0
- package/src/modes/setup-wizard/scenes/web-search.ts +17 -9
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +4 -1
- package/src/modes/utils/context-usage.ts +23 -7
- package/src/modes/utils/ui-helpers.ts +10 -5
- package/src/plan-mode/plan-files.ts +40 -0
- package/src/prompts/bench/cache-prefix-chunk.md +1 -0
- package/src/prompts/bench/cache-prefix.md +3 -0
- package/src/prompts/bench/cache-suffix.md +1 -0
- package/src/prompts/system/computer-safety.md +14 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/project-prompt.md +8 -1
- package/src/prompts/system/subagent-async-pending.md +6 -0
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/system/workflow-notice.md +48 -40
- package/src/prompts/system/xdev-mount-notice.md +4 -0
- package/src/prompts/tools/computer.md +26 -0
- package/src/prompts/tools/todo.md +3 -1
- package/src/registry/agent-lifecycle.ts +116 -46
- package/src/registry/agent-registry.ts +35 -8
- package/src/registry/persisted-agents.ts +26 -2
- package/src/sdk.ts +215 -136
- package/src/secrets/index.ts +127 -4
- package/src/secrets/obfuscator.ts +2328 -105
- package/src/session/acp-permission-gate.ts +165 -0
- package/src/session/agent-session-error-log.test.ts +1 -1
- package/src/session/agent-session-events.ts +66 -0
- package/src/session/agent-session-types.ts +343 -0
- package/src/session/agent-session.ts +1940 -12218
- package/src/session/artifacts.ts +6 -5
- package/src/session/async-job-delivery.ts +74 -0
- package/src/session/auth-storage.ts +1 -0
- package/src/session/bash-runner.ts +326 -0
- package/src/session/blob-store.test.ts +56 -0
- package/src/session/blob-store.ts +18 -2
- package/src/session/checkpoint-entries.ts +81 -0
- package/src/session/eval-runner.ts +217 -0
- package/src/session/indexed-session-storage.ts +9 -2
- package/src/session/irc-bridge.ts +203 -0
- package/src/session/messages.ts +266 -1
- package/src/session/model-controls.ts +714 -0
- package/src/session/prewalk.ts +252 -0
- package/src/session/queued-messages.ts +93 -0
- package/src/session/redis-session-storage.ts +51 -11
- package/src/session/retry-fallback-chains.ts +455 -0
- package/src/session/role-models.ts +85 -0
- package/src/session/session-advisors.ts +1679 -0
- package/src/session/session-context.ts +6 -3
- package/src/session/session-entries.ts +8 -0
- package/src/session/session-handoff.ts +308 -0
- package/src/session/session-history-format.ts +20 -9
- package/src/session/session-loader.ts +0 -46
- package/src/session/session-maintenance.ts +2983 -0
- package/src/session/session-manager.ts +390 -30
- package/src/session/session-memory.ts +222 -0
- package/src/session/session-provider-boundary.ts +307 -0
- package/src/session/session-stats.ts +293 -0
- package/src/session/session-tools.ts +980 -0
- package/src/session/session-workspace.ts +53 -0
- package/src/session/stream-guards.ts +417 -0
- package/src/session/streaming-output.ts +52 -5
- package/src/session/todo-tracker.ts +380 -0
- package/src/session/ttsr-coordinator.ts +496 -0
- package/src/session/turn-recovery.ts +1629 -0
- package/src/slash-commands/builtin-registry.ts +257 -20
- package/src/slash-commands/helpers/session-pin.ts +44 -0
- package/src/slash-commands/helpers/usage-report.ts +22 -3
- package/src/stt/downloader.ts +0 -2
- package/src/stt/index.ts +0 -2
- package/src/stt/stt-controller.ts +57 -146
- package/src/system-prompt.ts +45 -17
- package/src/task/executor.ts +285 -70
- package/src/task/index.ts +98 -105
- package/src/task/output-manager.ts +25 -3
- package/src/task/persisted-revive.ts +4 -3
- package/src/task/structured-subagent.ts +1 -0
- package/src/tiny/title-client.ts +22 -0
- package/src/tools/approval.ts +55 -10
- package/src/tools/bash-interactive.ts +116 -86
- package/src/tools/bash-skill-urls.ts +28 -2
- package/src/tools/bash.ts +339 -123
- package/src/tools/browser/launch.ts +9 -1
- package/src/tools/browser/tab-supervisor.ts +8 -5
- package/src/tools/browser.ts +94 -47
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/computer/protocol.ts +28 -0
- package/src/tools/computer/supervisor.ts +258 -0
- package/src/tools/computer/worker-entry.ts +25 -0
- package/src/tools/computer/worker.ts +135 -0
- package/src/tools/computer-renderer.ts +108 -0
- package/src/tools/computer.ts +433 -0
- package/src/tools/context.ts +2 -0
- package/src/tools/default-renderer.ts +139 -0
- package/src/tools/essential-tools.ts +1 -0
- package/src/tools/image-gen.ts +20 -28
- package/src/tools/image-providers.ts +50 -0
- package/src/tools/index.ts +14 -0
- package/src/tools/path-utils.ts +49 -2
- package/src/tools/read.ts +156 -88
- package/src/tools/renderers.ts +9 -1
- package/src/tools/todo.ts +101 -11
- package/src/tools/vibe.ts +1 -2
- package/src/tools/write.ts +51 -1
- package/src/tools/xdev.ts +121 -29
- package/src/tts/streaming-player.ts +82 -283
- package/src/tts/vocalizer.ts +18 -1
- package/src/utils/clipboard.ts +1 -30
- package/src/utils/jj.ts +125 -4
- package/src/utils/lang-from-path.ts +7 -0
- package/src/utils/mac-file-urls.applescript +37 -0
- package/src/utils/title-generator.ts +137 -1
- package/src/utils/tool-choice.ts +14 -0
- package/src/utils/tools-manager.ts +1 -19
- package/src/vibe/runtime.ts +852 -72
- package/src/web/search/index.ts +3 -6
- package/src/web/search/provider.ts +31 -16
- package/src/web/search/providers/xai.ts +7 -1
- package/src/web/search/types.ts +3 -0
- package/dist/types/stt/recorder.d.ts +0 -30
- package/dist/types/stt/transcriber.d.ts +0 -14
- package/dist/types/stt/wav.d.ts +0 -29
- package/dist/types/tts/player.d.ts +0 -32
- package/src/stt/recorder.ts +0 -551
- package/src/stt/transcriber.ts +0 -60
- package/src/stt/wav.ts +0 -173
- package/src/tts/player.ts +0 -137
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Filesystem workspace of a session: one current/default directory plus a
|
|
6
|
+
* non-empty ordered list of workspace directories.
|
|
7
|
+
*
|
|
8
|
+
* `cwd` remains the default directory for relative-path resolution and
|
|
9
|
+
* backward compatibility. `directories` always contains `cwd` first, followed
|
|
10
|
+
* by any additional directories in their supplied order (deduplicated).
|
|
11
|
+
* Directory order is stable but carries no semantic hierarchy.
|
|
12
|
+
*
|
|
13
|
+
* Workspace directories come from the platform (ACP/editor), CLI, or config —
|
|
14
|
+
* never from filesystem walk-up discovery.
|
|
15
|
+
*/
|
|
16
|
+
export interface SessionWorkspace {
|
|
17
|
+
/** Current/default directory for compatibility and relative path resolution. */
|
|
18
|
+
cwd: string;
|
|
19
|
+
/** Non-empty ordered list of absolute normalized directories; `cwd` is always first. */
|
|
20
|
+
directories: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Expand a leading `~`/`~/` and resolve to an absolute path (relative input resolves against `base`). */
|
|
24
|
+
export function normalizeWorkspaceDirectory(directory: string, base?: string): string {
|
|
25
|
+
let expanded = directory;
|
|
26
|
+
if (expanded === "~") {
|
|
27
|
+
expanded = os.homedir();
|
|
28
|
+
} else if (expanded.startsWith("~/") || expanded.startsWith(`~${path.sep}`)) {
|
|
29
|
+
expanded = path.join(os.homedir(), expanded.slice(2));
|
|
30
|
+
}
|
|
31
|
+
return base ? path.resolve(base, expanded) : path.resolve(expanded);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a normalized {@link SessionWorkspace} from a cwd and optional
|
|
36
|
+
* additional directories. Additional entries are normalized (relative entries
|
|
37
|
+
* resolve against the normalized cwd), deduplicated, and appended after `cwd`
|
|
38
|
+
* preserving their supplied order.
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeSessionWorkspace(args: { cwd: string; directories?: string[] }): SessionWorkspace {
|
|
41
|
+
const cwd = normalizeWorkspaceDirectory(args.cwd);
|
|
42
|
+
const directories = [cwd];
|
|
43
|
+
for (const directory of args.directories ?? []) {
|
|
44
|
+
const normalized = normalizeWorkspaceDirectory(directory, cwd);
|
|
45
|
+
if (!directories.includes(normalized)) directories.push(normalized);
|
|
46
|
+
}
|
|
47
|
+
return { cwd, directories };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The workspace directories beyond `cwd`, in order (ACP `additionalDirectories` shape). */
|
|
51
|
+
export function additionalWorkspaceDirectories(workspace: SessionWorkspace): string[] {
|
|
52
|
+
return workspace.directories.filter(directory => directory !== workspace.cwd);
|
|
53
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import type { Agent, AgentEvent, AgentMessage, AgentTurnEndContext } from "@oh-my-pi/pi-agent-core";
|
|
3
|
+
import type { AssistantMessage, AssistantMessageEvent, Model, ToolCall } from "@oh-my-pi/pi-ai";
|
|
4
|
+
import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
|
5
|
+
import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
|
|
6
|
+
import { isEnoent, logger, prompt } from "@oh-my-pi/pi-utils";
|
|
7
|
+
import type { Settings } from "../config/settings";
|
|
8
|
+
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
9
|
+
import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
|
|
10
|
+
import geminiToolReminderTemplate from "../prompts/system/gemini-tool-call-reminder.md" with { type: "text" };
|
|
11
|
+
import toolCallLoopRedirectTemplate from "../prompts/system/tool-call-loop-redirect.md" with { type: "text" };
|
|
12
|
+
import type { SecretObfuscator } from "../secrets/obfuscator";
|
|
13
|
+
import { assertEditableFile } from "../tools/auto-generated-guard";
|
|
14
|
+
import { isInternalUrlPath, normalizeLocalScheme, resolveToCwd } from "../tools/path-utils";
|
|
15
|
+
import { ToolError } from "../tools/tool-errors";
|
|
16
|
+
import type { CustomMessage } from "./messages";
|
|
17
|
+
import type { SessionManager } from "./session-manager";
|
|
18
|
+
|
|
19
|
+
const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
|
|
20
|
+
const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
|
|
21
|
+
const TOOL_CALL_LOOP_REDIRECT_TYPE = "tool-call-loop-redirect";
|
|
22
|
+
|
|
23
|
+
/** Capabilities borrowed by the session's streaming and loop guards. */
|
|
24
|
+
export interface StreamGuardsHost {
|
|
25
|
+
agent: Agent;
|
|
26
|
+
settings: Settings;
|
|
27
|
+
sessionManager: SessionManager;
|
|
28
|
+
obfuscator: SecretObfuscator | undefined;
|
|
29
|
+
model(): Model | undefined;
|
|
30
|
+
isDisposed(): boolean;
|
|
31
|
+
promptGeneration(): number;
|
|
32
|
+
localProtocolOptions(): LocalProtocolOptions;
|
|
33
|
+
emitNotice(level: "info" | "warning" | "error", message: string, source?: string): void;
|
|
34
|
+
schedulePostPromptTask(task: (signal: AbortSignal) => Promise<void>): void;
|
|
35
|
+
discardAssistantTurn(message: AssistantMessage): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Guards streamed edit calls against generated files and invalid patch previews. */
|
|
39
|
+
export class StreamingEditGuard {
|
|
40
|
+
readonly #host: StreamGuardsHost;
|
|
41
|
+
#abortTriggered = false;
|
|
42
|
+
#checkedLineCounts = new Map<string, number>();
|
|
43
|
+
#precheckedToolCallIds = new Set<string>();
|
|
44
|
+
#fileCache = new Map<string, string>();
|
|
45
|
+
#lastToolCallId: string | undefined;
|
|
46
|
+
|
|
47
|
+
constructor(host: StreamGuardsHost) {
|
|
48
|
+
this.#host = host;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether the current turn was aborted by streaming edit validation. */
|
|
52
|
+
get abortTriggered(): boolean {
|
|
53
|
+
return this.#abortTriggered;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Clears all turn-scoped streaming edit state. */
|
|
57
|
+
reset(): void {
|
|
58
|
+
this.#abortTriggered = false;
|
|
59
|
+
this.#checkedLineCounts.clear();
|
|
60
|
+
this.#precheckedToolCallIds.clear();
|
|
61
|
+
this.#fileCache.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Pre-caches and validates a streamed edit as its arguments arrive. */
|
|
65
|
+
preCache(event: AgentEvent): void {
|
|
66
|
+
if (this.#abortTriggered || event.type !== "message_update") return;
|
|
67
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
68
|
+
if (
|
|
69
|
+
assistantEvent.type !== "toolcall_start" &&
|
|
70
|
+
assistantEvent.type !== "toolcall_delta" &&
|
|
71
|
+
assistantEvent.type !== "toolcall_end"
|
|
72
|
+
) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const streamingEdit = this.#getToolCall(event);
|
|
76
|
+
if (!streamingEdit) return;
|
|
77
|
+
|
|
78
|
+
// The auto-generated guard runs unconditionally: editing a generated file
|
|
79
|
+
// is never the user's intent, and the cost of a false-positive abort is one
|
|
80
|
+
// wasted turn vs. silently corrupting a regenerated source.
|
|
81
|
+
const shouldCheckAutoGenerated =
|
|
82
|
+
!streamingEdit.toolCall.id || !this.#precheckedToolCallIds.has(streamingEdit.toolCall.id);
|
|
83
|
+
if (shouldCheckAutoGenerated) {
|
|
84
|
+
if (streamingEdit.toolCall.id) this.#precheckedToolCallIds.add(streamingEdit.toolCall.id);
|
|
85
|
+
this.#abortForAutoGeneratedPath(streamingEdit.toolCall, streamingEdit.path, streamingEdit.resolvedPath);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// File-cache priming feeds maybeAbort's removed-lines check, which is the
|
|
89
|
+
// optional patch-preview verification gated by edit.streamingAbort.
|
|
90
|
+
if (this.#host.settings.get("edit.streamingAbort")) this.#ensureFileCache(streamingEdit.resolvedPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Invalidates cached source text after an edit tool result lands. */
|
|
94
|
+
invalidate(filePath: string): void {
|
|
95
|
+
const resolvedPath = this.#resolveSessionFsPath(filePath);
|
|
96
|
+
if (resolvedPath !== undefined) this.#fileCache.delete(resolvedPath);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Aborts a streamed edit whose completed patch preview cannot apply. */
|
|
100
|
+
maybeAbort(event: AgentEvent): void {
|
|
101
|
+
if (!this.#host.settings.get("edit.streamingAbort") || this.#abortTriggered || event.type !== "message_update") {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const assistantEvent = event.assistantMessageEvent;
|
|
105
|
+
if (assistantEvent.type !== "toolcall_end" && assistantEvent.type !== "toolcall_delta") return;
|
|
106
|
+
const streamingEdit = this.#getToolCall(event);
|
|
107
|
+
if (!streamingEdit?.toolCall.id) return;
|
|
108
|
+
|
|
109
|
+
const { toolCall, path, resolvedPath, diff, op, rename } = streamingEdit;
|
|
110
|
+
if (!diff || (op && op !== "update") || !diff.includes("\n")) return;
|
|
111
|
+
const lastNewlineIndex = diff.lastIndexOf("\n");
|
|
112
|
+
if (lastNewlineIndex < 0) return;
|
|
113
|
+
const diffForCheck = diff.endsWith("\n") ? diff : diff.slice(0, lastNewlineIndex + 1);
|
|
114
|
+
if (diffForCheck.trim().length === 0) return;
|
|
115
|
+
|
|
116
|
+
let normalizedDiff = normalizeDiff(diffForCheck.replace(/\r/g, ""));
|
|
117
|
+
if (!normalizedDiff) return;
|
|
118
|
+
if (this.#host.obfuscator) normalizedDiff = this.#host.obfuscator.deobfuscate(normalizedDiff);
|
|
119
|
+
if (!normalizedDiff) return;
|
|
120
|
+
const lines = normalizedDiff.split("\n");
|
|
121
|
+
if (!lines.some(line => line.startsWith("+") || line.startsWith("-"))) return;
|
|
122
|
+
|
|
123
|
+
const lineCount = lines.length;
|
|
124
|
+
const lastChecked = this.#checkedLineCounts.get(toolCall.id);
|
|
125
|
+
if (lastChecked !== undefined && lineCount <= lastChecked) return;
|
|
126
|
+
this.#checkedLineCounts.set(toolCall.id, lineCount);
|
|
127
|
+
|
|
128
|
+
const removedLines = lines
|
|
129
|
+
.filter(line => line.startsWith("-") && !line.startsWith("--- "))
|
|
130
|
+
.map(line => line.slice(1));
|
|
131
|
+
if (removedLines.length > 0) {
|
|
132
|
+
let cachedContent = this.#fileCache.get(resolvedPath);
|
|
133
|
+
if (cachedContent === undefined) {
|
|
134
|
+
this.#ensureFileCache(resolvedPath);
|
|
135
|
+
cachedContent = this.#fileCache.get(resolvedPath);
|
|
136
|
+
}
|
|
137
|
+
if (cachedContent !== undefined) {
|
|
138
|
+
const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
|
|
139
|
+
if (missing) this.#abortPatch(toolCall.id, path, `Failed to find expected lines in ${path}:\n${missing}`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (assistantEvent.type === "toolcall_delta") return;
|
|
143
|
+
void this.#checkRemovedLines(toolCall.id, path, resolvedPath, removedLines);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (assistantEvent.type === "toolcall_delta") return;
|
|
147
|
+
void this.#checkPreviewPatch(toolCall.id, path, rename, normalizedDiff);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#getToolCall(event: AgentEvent):
|
|
151
|
+
| {
|
|
152
|
+
toolCall: ToolCall;
|
|
153
|
+
path: string;
|
|
154
|
+
resolvedPath: string;
|
|
155
|
+
diff?: string;
|
|
156
|
+
op?: string;
|
|
157
|
+
rename?: string;
|
|
158
|
+
}
|
|
159
|
+
| undefined {
|
|
160
|
+
if (event.type !== "message_update" || event.message.role !== "assistant") return undefined;
|
|
161
|
+
const contentIndex = event.assistantMessageEvent.contentIndex ?? 0;
|
|
162
|
+
const messageContent = event.message.content;
|
|
163
|
+
if (!Array.isArray(messageContent) || contentIndex < 0 || contentIndex >= messageContent.length) return undefined;
|
|
164
|
+
const toolCall = messageContent[contentIndex] as ToolCall;
|
|
165
|
+
if (toolCall.name !== "edit") return undefined;
|
|
166
|
+
const args = toolCall.arguments;
|
|
167
|
+
if (!args || typeof args !== "object" || Array.isArray(args) || "old_text" in args || "new_text" in args) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
const filePath = typeof args.path === "string" ? args.path : undefined;
|
|
171
|
+
if (!filePath) return undefined;
|
|
172
|
+
// local:// URLs resolve to artifacts; other internal URLs have no local path.
|
|
173
|
+
const resolvedPath = this.#resolveSessionFsPath(filePath);
|
|
174
|
+
if (resolvedPath === undefined) return undefined;
|
|
175
|
+
return {
|
|
176
|
+
toolCall,
|
|
177
|
+
path: filePath,
|
|
178
|
+
resolvedPath,
|
|
179
|
+
diff: typeof args.diff === "string" ? args.diff : undefined,
|
|
180
|
+
op: typeof args.op === "string" ? args.op : undefined,
|
|
181
|
+
rename: typeof args.rename === "string" ? args.rename : undefined,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#abortForAutoGeneratedPath(toolCall: ToolCall, filePath: string, resolvedPath: string): void {
|
|
186
|
+
if (this.#lastToolCallId === toolCall.id) return;
|
|
187
|
+
this.#lastToolCallId = toolCall.id;
|
|
188
|
+
void assertEditableFile(resolvedPath, filePath).catch(error => {
|
|
189
|
+
if (!(error instanceof ToolError) || this.#lastToolCallId !== toolCall.id) return;
|
|
190
|
+
if (!this.#abortTriggered) {
|
|
191
|
+
this.#abortTriggered = true;
|
|
192
|
+
logger.warn("Streaming edit aborted due to auto-generated file guard", {
|
|
193
|
+
toolCallId: toolCall.id,
|
|
194
|
+
path: filePath,
|
|
195
|
+
});
|
|
196
|
+
this.#host.agent.abort();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#ensureFileCache(resolvedPath: string): void {
|
|
202
|
+
if (this.#fileCache.has(resolvedPath)) return;
|
|
203
|
+
try {
|
|
204
|
+
const rawText = fs.readFileSync(resolvedPath, "utf-8");
|
|
205
|
+
const { text } = stripBom(rawText);
|
|
206
|
+
this.#fileCache.set(resolvedPath, normalizeToLF(text));
|
|
207
|
+
} catch {
|
|
208
|
+
// Read errors are handled by the edit tool itself.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
#resolveSessionFsPath(filePath: string): string | undefined {
|
|
213
|
+
const normalized = normalizeLocalScheme(filePath);
|
|
214
|
+
if (normalized.startsWith("local:")) {
|
|
215
|
+
return resolveLocalUrlToPath(normalized, this.#host.localProtocolOptions());
|
|
216
|
+
}
|
|
217
|
+
if (isInternalUrlPath(normalized)) return undefined;
|
|
218
|
+
return resolveToCwd(normalized, this.#host.sessionManager.getCwd());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async #checkRemovedLines(
|
|
222
|
+
toolCallId: string,
|
|
223
|
+
filePath: string,
|
|
224
|
+
resolvedPath: string,
|
|
225
|
+
removedLines: string[],
|
|
226
|
+
): Promise<void> {
|
|
227
|
+
if (this.#abortTriggered) return;
|
|
228
|
+
try {
|
|
229
|
+
const { text } = stripBom(await Bun.file(resolvedPath).text());
|
|
230
|
+
const normalizedContent = normalizeToLF(text);
|
|
231
|
+
const missing = removedLines.find(line => !normalizedContent.includes(normalizeToLF(line)));
|
|
232
|
+
if (missing)
|
|
233
|
+
this.#abortPatch(toolCallId, filePath, `Failed to find expected lines in ${filePath}:\n${missing}`);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (!isEnoent(error)) {
|
|
236
|
+
// Unexpected fallback read errors remain non-fatal.
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async #checkPreviewPatch(
|
|
242
|
+
toolCallId: string,
|
|
243
|
+
filePath: string,
|
|
244
|
+
rename: string | undefined,
|
|
245
|
+
normalizedDiff: string,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
if (this.#abortTriggered) return;
|
|
248
|
+
try {
|
|
249
|
+
await previewPatch(
|
|
250
|
+
{ path: filePath, op: "update", rename, diff: normalizedDiff },
|
|
251
|
+
{
|
|
252
|
+
cwd: this.#host.sessionManager.getCwd(),
|
|
253
|
+
allowFuzzy: this.#host.settings.get("edit.fuzzyMatch"),
|
|
254
|
+
fuzzyThreshold: this.#host.settings.get("edit.fuzzyThreshold"),
|
|
255
|
+
},
|
|
256
|
+
);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error instanceof ParseError) return;
|
|
259
|
+
this.#abortPatch(toolCallId, filePath, error instanceof Error ? error.message : String(error));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#abortPatch(toolCallId: string, filePath: string, error: string): void {
|
|
264
|
+
this.#abortTriggered = true;
|
|
265
|
+
logger.warn("Streaming edit aborted due to patch preview failure", { toolCallId, path: filePath, error });
|
|
266
|
+
this.#host.agent.abort();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Detects cross-turn tool loops and Gemini reasoning-header runaways. */
|
|
271
|
+
export class LoopGuards {
|
|
272
|
+
readonly #host: StreamGuardsHost;
|
|
273
|
+
#geminiHeaderDetector: GeminiHeaderRunDetector | undefined;
|
|
274
|
+
#toolCallLoopGuard: ToolCallLoopGuard | undefined;
|
|
275
|
+
#toolCallLoopGuardSettingsKey: string | undefined;
|
|
276
|
+
|
|
277
|
+
constructor(host: StreamGuardsHost) {
|
|
278
|
+
this.#host = host;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Records a completed turn and injects a redirect when calls repeat. */
|
|
282
|
+
recordTurn(messages: AgentMessage[], context: AgentTurnEndContext | undefined): void {
|
|
283
|
+
if (context?.message.role !== "assistant") return;
|
|
284
|
+
const detection = this.#activeToolCallLoopGuard()?.recordTurn({
|
|
285
|
+
message: context.message,
|
|
286
|
+
toolResults: context.toolResults,
|
|
287
|
+
});
|
|
288
|
+
if (detection) this.#injectToolCallLoopRedirect(messages, detection);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Feeds a streamed assistant event to the Gemini header-runaway detector. */
|
|
292
|
+
onAssistantEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
|
|
293
|
+
if (event.type === "thinking_start") {
|
|
294
|
+
this.#geminiHeaderDetector = this.#geminiHeaderGuardActive() ? new GeminiHeaderRunDetector() : undefined;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const detector = this.#geminiHeaderDetector;
|
|
298
|
+
if (!detector) return;
|
|
299
|
+
if (event.type === "thinking_delta") {
|
|
300
|
+
if (detector.push(event.delta)) this.#interruptGeminiHeaderRunaway(detector.count, message.timestamp);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (event.type === "text_start" || event.type === "toolcall_start") detector.reset();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#activeToolCallLoopGuard(): ToolCallLoopGuard | undefined {
|
|
307
|
+
if (this.#host.settings.get("model.toolCallLoopGuard.enabled") !== true) {
|
|
308
|
+
this.#toolCallLoopGuard = undefined;
|
|
309
|
+
this.#toolCallLoopGuardSettingsKey = undefined;
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
const threshold = this.#host.settings.get("model.toolCallLoopGuard.threshold");
|
|
313
|
+
const exemptTools = this.#host.settings
|
|
314
|
+
.get("model.toolCallLoopGuard.exemptTools")
|
|
315
|
+
.filter((tool): tool is string => typeof tool === "string" && tool.length > 0);
|
|
316
|
+
const settingsKey = `${threshold}:${JSON.stringify(exemptTools)}`;
|
|
317
|
+
if (!this.#toolCallLoopGuard || this.#toolCallLoopGuardSettingsKey !== settingsKey) {
|
|
318
|
+
this.#toolCallLoopGuard = new ToolCallLoopGuard({ threshold, exemptTools });
|
|
319
|
+
this.#toolCallLoopGuardSettingsKey = settingsKey;
|
|
320
|
+
}
|
|
321
|
+
return this.#toolCallLoopGuard;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#injectToolCallLoopRedirect(messages: AgentMessage[], detection: RepeatedToolCallDetection): void {
|
|
325
|
+
const content = prompt.render(toolCallLoopRedirectTemplate, {
|
|
326
|
+
tool_name: detection.toolName,
|
|
327
|
+
count: detection.count,
|
|
328
|
+
arguments_summary: detection.argumentsSummary,
|
|
329
|
+
result_summary: detection.resultSummary || "(no text result)",
|
|
330
|
+
});
|
|
331
|
+
const details = {
|
|
332
|
+
toolName: detection.toolName,
|
|
333
|
+
count: detection.count,
|
|
334
|
+
argumentsSummary: detection.argumentsSummary,
|
|
335
|
+
resultSummary: detection.resultSummary,
|
|
336
|
+
};
|
|
337
|
+
logger.warn("cross-turn tool-call loop detected", { toolName: detection.toolName, count: detection.count });
|
|
338
|
+
const redirectMessage: CustomMessage = {
|
|
339
|
+
role: "custom",
|
|
340
|
+
customType: TOOL_CALL_LOOP_REDIRECT_TYPE,
|
|
341
|
+
content,
|
|
342
|
+
display: false,
|
|
343
|
+
details,
|
|
344
|
+
attribution: "agent",
|
|
345
|
+
timestamp: Date.now(),
|
|
346
|
+
};
|
|
347
|
+
messages.push(redirectMessage);
|
|
348
|
+
if (this.#host.agent.state.messages !== messages) this.#host.agent.appendMessage(redirectMessage);
|
|
349
|
+
this.#host.sessionManager.appendCustomMessageEntry(
|
|
350
|
+
TOOL_CALL_LOOP_REDIRECT_TYPE,
|
|
351
|
+
content,
|
|
352
|
+
false,
|
|
353
|
+
details,
|
|
354
|
+
"agent",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#geminiHeaderGuardActive(): boolean {
|
|
359
|
+
const model = this.#host.model();
|
|
360
|
+
return (
|
|
361
|
+
process.env.PI_NO_THINKING_LOOP_GUARD !== "1" &&
|
|
362
|
+
this.#host.settings.get("model.loopGuard.enabled") === true &&
|
|
363
|
+
this.#host.settings.get("model.loopGuard.toolCallReminder") === true &&
|
|
364
|
+
model !== undefined &&
|
|
365
|
+
isGeminiThinkingModel(model)
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
#interruptGeminiHeaderRunaway(headerCount: number, targetTimestamp: number): void {
|
|
370
|
+
const model = this.#host.model();
|
|
371
|
+
logger.warn("Gemini reasoning-header runaway; interrupting to require a tool call", {
|
|
372
|
+
model: model?.id,
|
|
373
|
+
provider: model?.provider,
|
|
374
|
+
headers: headerCount,
|
|
375
|
+
});
|
|
376
|
+
this.#host.emitNotice(
|
|
377
|
+
"warning",
|
|
378
|
+
`Interrupted ${headerCount} planning headers with no tool call; reminded the model to issue one.`,
|
|
379
|
+
"loop-guard",
|
|
380
|
+
);
|
|
381
|
+
this.#host.agent.abort(GEMINI_HEADER_INTERRUPT_REASON);
|
|
382
|
+
const generation = this.#host.promptGeneration();
|
|
383
|
+
this.#host.schedulePostPromptTask(async signal => {
|
|
384
|
+
if (signal.aborted || this.#host.isDisposed() || this.#host.promptGeneration() !== generation) return;
|
|
385
|
+
await this.#host.agent.waitForIdle();
|
|
386
|
+
if (signal.aborted || this.#host.isDisposed() || this.#host.promptGeneration() !== generation) return;
|
|
387
|
+
const aborted = this.#host.agent.state.messages.findLast(
|
|
388
|
+
(message): message is AssistantMessage =>
|
|
389
|
+
message.role === "assistant" && message.timestamp === targetTimestamp,
|
|
390
|
+
);
|
|
391
|
+
if (aborted) this.#host.discardAssistantTurn(aborted);
|
|
392
|
+
const content = prompt.render(geminiToolReminderTemplate, { count: headerCount });
|
|
393
|
+
const details = { headers: headerCount };
|
|
394
|
+
this.#host.agent.appendMessage({
|
|
395
|
+
role: "custom",
|
|
396
|
+
customType: GEMINI_TOOL_REMINDER_TYPE,
|
|
397
|
+
content,
|
|
398
|
+
display: false,
|
|
399
|
+
details,
|
|
400
|
+
attribution: "agent",
|
|
401
|
+
timestamp: Date.now(),
|
|
402
|
+
});
|
|
403
|
+
this.#host.sessionManager.appendCustomMessageEntry(
|
|
404
|
+
GEMINI_TOOL_REMINDER_TYPE,
|
|
405
|
+
content,
|
|
406
|
+
false,
|
|
407
|
+
details,
|
|
408
|
+
"agent",
|
|
409
|
+
);
|
|
410
|
+
try {
|
|
411
|
+
await this.#host.agent.continue();
|
|
412
|
+
} catch (error) {
|
|
413
|
+
logger.warn("gemini tool-call reminder continue failed", { error: String(error) });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
@@ -756,6 +756,10 @@ export class OutputSink {
|
|
|
756
756
|
// Queue of chunks waiting for the file sink to be created.
|
|
757
757
|
#pendingFileWrites?: string[];
|
|
758
758
|
#fileReady = false;
|
|
759
|
+
/** In-flight sink creation, awaited by finalize/dispose so a fd opened by a late chunk is still released. */
|
|
760
|
+
#fileCreation?: Promise<void>;
|
|
761
|
+
/** Set once the spill file has been closed; guards double-close and post-finalize resurrection. */
|
|
762
|
+
#finalized = false;
|
|
759
763
|
|
|
760
764
|
readonly #artifactPath?: string;
|
|
761
765
|
readonly #artifactId?: string;
|
|
@@ -842,6 +846,7 @@ export class OutputSink {
|
|
|
842
846
|
* synchronously. File sink writes are deferred and serialized internally.
|
|
843
847
|
*/
|
|
844
848
|
push(chunk: string): void {
|
|
849
|
+
if (this.#finalized) return;
|
|
845
850
|
chunk = sanitizeWithOptionalSixelPassthrough(chunk, text => sanitizeText(this.#normalizeCarriageReturns(text)));
|
|
846
851
|
|
|
847
852
|
// Throttled onChunk: coalesce chunks arriving inside the throttle window.
|
|
@@ -1018,7 +1023,7 @@ export class OutputSink {
|
|
|
1018
1023
|
// resolves (typically <2). The cap is enforced on drain.
|
|
1019
1024
|
if (!this.#pendingFileWrites) {
|
|
1020
1025
|
this.#pendingFileWrites = [chunk];
|
|
1021
|
-
|
|
1026
|
+
this.#fileCreation = this.#createFileSink();
|
|
1022
1027
|
} else {
|
|
1023
1028
|
this.#pendingFileWrites.push(chunk);
|
|
1024
1029
|
}
|
|
@@ -1255,10 +1260,7 @@ export class OutputSink {
|
|
|
1255
1260
|
this.#flushPendingChunk();
|
|
1256
1261
|
const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
|
|
1257
1262
|
|
|
1258
|
-
|
|
1259
|
-
this.#flushArtifactTailIfCapped();
|
|
1260
|
-
await this.#file.sink.end();
|
|
1261
|
-
}
|
|
1263
|
+
await this.#finalizeFile();
|
|
1262
1264
|
|
|
1263
1265
|
// Compose the visible output. With head retention, splice head + marker
|
|
1264
1266
|
// + tail when content was elided. Otherwise return the rolling buffer.
|
|
@@ -1321,6 +1323,51 @@ export class OutputSink {
|
|
|
1321
1323
|
artifactId: this.#file?.artifactId,
|
|
1322
1324
|
};
|
|
1323
1325
|
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Flush any capped artifact tail and close the spill file descriptor,
|
|
1329
|
+
* awaiting an in-flight sink creation so a descriptor opened by a late
|
|
1330
|
+
* chunk is still released. Idempotent via {@link #finalized}: the artifact
|
|
1331
|
+
* is finalized exactly once whether the caller reached {@link dump} or
|
|
1332
|
+
* bailed through {@link dispose}. `#file` is left set so {@link dump} can
|
|
1333
|
+
* still read `artifactId` for its summary.
|
|
1334
|
+
*/
|
|
1335
|
+
async #finalizeFile(): Promise<void> {
|
|
1336
|
+
if (this.#finalized) return;
|
|
1337
|
+
this.#finalized = true;
|
|
1338
|
+
if (this.#fileCreation) {
|
|
1339
|
+
await this.#fileCreation.catch(() => undefined);
|
|
1340
|
+
}
|
|
1341
|
+
const file = this.#file;
|
|
1342
|
+
if (!file) return;
|
|
1343
|
+
// The tail/notice replay writes to the sink and can throw (e.g. a disk
|
|
1344
|
+
// write error). Closing the descriptor MUST still happen — otherwise the
|
|
1345
|
+
// fd leaks and the replay error masks the original tool error that put us
|
|
1346
|
+
// on this path. Both failures are swallowed so dispose() never throws.
|
|
1347
|
+
try {
|
|
1348
|
+
this.#flushArtifactTailIfCapped();
|
|
1349
|
+
} catch {
|
|
1350
|
+
/* ignore */
|
|
1351
|
+
} finally {
|
|
1352
|
+
try {
|
|
1353
|
+
await file.sink.end();
|
|
1354
|
+
} catch {
|
|
1355
|
+
/* ignore */
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Release the artifact spill descriptor on an exit path that skips
|
|
1362
|
+
* {@link dump} — a thrown error or abort. Idempotent and safe in a
|
|
1363
|
+
* `finally`: if {@link dump} already ran this is a no-op, otherwise it
|
|
1364
|
+
* flushes the capped tail and closes the sink so the descriptor is not
|
|
1365
|
+
* leaked until a later unrelated read hits `EMFILE` (issue #6463).
|
|
1366
|
+
*/
|
|
1367
|
+
async dispose(): Promise<void> {
|
|
1368
|
+
this.#clearPendingChunkTimer();
|
|
1369
|
+
await this.#finalizeFile();
|
|
1370
|
+
}
|
|
1324
1371
|
}
|
|
1325
1372
|
|
|
1326
1373
|
// =============================================================================
|