@oh-my-pi/pi-coding-agent 16.2.7 → 16.2.9
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 +53 -0
- package/dist/cli.js +3661 -3482
- package/dist/types/config/settings-schema.d.ts +97 -13
- package/dist/types/edit/hashline/filesystem.d.ts +1 -0
- package/dist/types/modes/components/mcp-add-wizard.d.ts +8 -0
- package/dist/types/modes/controllers/mcp-command-controller.d.ts +9 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +0 -5
- package/dist/types/session/settings-stream-fn.d.ts +2 -2
- package/dist/types/stt/index.d.ts +1 -0
- package/dist/types/stt/stt-controller.d.ts +2 -0
- package/dist/types/stt/submit-trigger.d.ts +30 -0
- package/dist/types/task/index.d.ts +1 -1
- package/dist/types/task/types.d.ts +6 -6
- package/dist/types/tiny/models.d.ts +22 -8
- package/dist/types/tools/output-schema-validator.d.ts +10 -0
- package/package.json +12 -12
- package/src/commit/agentic/agent.ts +1 -1
- package/src/commit/agentic/prompts/system.md +1 -1
- package/src/commit/agentic/tools/analyze-file.ts +2 -2
- package/src/config/settings-schema.ts +54 -2
- package/src/debug/profiler.ts +7 -1
- package/src/discovery/builtin-rules/go-add-cleanup.md +32 -0
- package/src/discovery/builtin-rules/go-bench-loop.md +36 -0
- package/src/discovery/builtin-rules/go-exp-promoted.md +39 -0
- package/src/discovery/builtin-rules/go-ioutil.md +36 -0
- package/src/discovery/builtin-rules/go-join-hostport.md +29 -0
- package/src/discovery/builtin-rules/go-new-expr.md +44 -0
- package/src/discovery/builtin-rules/go-rand-v2.md +40 -0
- package/src/discovery/builtin-rules/go-range-int.md +45 -0
- package/src/discovery/builtin-rules/index.ts +16 -0
- package/src/edit/hashline/filesystem.ts +12 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/oauth-flow.ts +35 -8
- package/src/modes/components/mcp-add-wizard.ts +43 -3
- package/src/modes/components/model-selector.ts +21 -9
- package/src/modes/controllers/event-controller.ts +9 -0
- package/src/modes/controllers/mcp-command-controller.ts +84 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +5 -4
- package/src/prompts/agents/tester.md +107 -0
- package/src/prompts/system/orchestrate-notice.md +2 -2
- package/src/prompts/system/system-prompt.md +2 -5
- package/src/prompts/system/thinking-loop-redirect.md +10 -0
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +1 -4
- package/src/prompts/tools/task.md +2 -9
- package/src/session/agent-session.ts +161 -24
- package/src/session/session-context.ts +13 -2
- package/src/session/settings-stream-fn.ts +12 -2
- package/src/stt/index.ts +1 -0
- package/src/stt/stt-controller.ts +31 -2
- package/src/stt/submit-trigger.ts +74 -0
- package/src/task/agents.ts +4 -4
- package/src/task/executor.ts +1 -1
- package/src/task/index.ts +18 -5
- package/src/task/types.ts +5 -5
- package/src/tiny/models.ts +10 -0
- package/src/tools/grep.ts +19 -1
- package/src/tools/output-schema-validator.ts +38 -0
- package/src/tools/yield.ts +52 -15
- package/src/prompts/agents/oracle.md +0 -54
|
@@ -13,7 +13,7 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
|
|
|
13
13
|
<helpers>
|
|
14
14
|
State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
|
|
15
15
|
|
|
16
|
-
- `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer",
|
|
16
|
+
- `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
|
|
17
17
|
- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
|
|
18
18
|
- `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
|
|
19
19
|
- `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
|
|
@@ -29,13 +29,10 @@ Anything below → `eval` cell, not bash:
|
|
|
29
29
|
|
|
30
30
|
<critical>
|
|
31
31
|
- Bash invokes real binaries with simple args; it is NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
|
|
32
|
-
- NEVER shell out to search content or files: `grep/rg` → `grep`.
|
|
33
|
-
- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `glob` tool (globbing). This is non-negotiable, even for a single quick listing.
|
|
34
|
-
- Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
|
|
35
32
|
</critical>
|
|
36
33
|
|
|
37
34
|
<output>
|
|
38
|
-
- Returns output; exit code shown on non-zero exit.
|
|
35
|
+
- Returns output (stderr merged into stdout); exit code shown on non-zero exit.
|
|
39
36
|
- Truncated output → `artifact://<id>` (linked in metadata).
|
|
40
37
|
</output>
|
|
41
38
|
|
|
@@ -7,11 +7,10 @@ Execution blocks your turn: the call only returns once the work is completely fi
|
|
|
7
7
|
- **Sequence only when necessary:** The only reason to run A before B is if B strictly requires A's output to function (e.g., a core API contract or schema migration). {{#if ircEnabled}}If the missing piece is small, run them in parallel and have B ask A via `irc`!{{/if}}
|
|
8
8
|
- **Role matching:** Assign each subagent a specific `role` (e.g. "Security Reviewer", "DB Migrator"). Do not spawn generic workers.
|
|
9
9
|
- **No overhead:** Each assignment MUST instruct its agent to skip formatters, linters, and project-wide test suites. You will run those once at the end.
|
|
10
|
-
- **Do your own thinking:** NEVER assign reasoning, architecture, or design to `quick_task` or `explore`. They are for mechanical lookups only. Keep hard decisions in your own context or use `task`, `plan`, or `oracle`.
|
|
11
10
|
- **One-pass agents:** Prefer agents that investigate **and** edit in a single pass; only spin a read-only discovery step (e.g. `explore`) when the affected files are genuinely unknown.
|
|
12
11
|
|
|
13
12
|
# Inputs
|
|
14
|
-
- `agent
|
|
13
|
+
- `agent` (optional): The base agent type to use (e.g., `explore`, `reviewer`). Defaults to `task` (the general-purpose worker) — omit it for the default worker instead of passing `agent: "task"`.
|
|
15
14
|
{{#if batchEnabled}}
|
|
16
15
|
- `context`: Shared project state, constraints, and contracts. Applies to the entire batch; do not duplicate this background into individual tasks.
|
|
17
16
|
- `tasks[]`: Array of subagents to spawn.
|
|
@@ -37,13 +36,7 @@ Subagents start blank. They have no access to your conversation history.
|
|
|
37
36
|
{{#if batchEnabled}}
|
|
38
37
|
- Pass large payloads using `local://<path>` URIs, never inline text.
|
|
39
38
|
{{else}}
|
|
40
|
-
-
|
|
41
|
-
{{/if}}
|
|
42
|
-
{{#if ircEnabled}}
|
|
43
|
-
- Once spawned, coordinate with live agents via `irc` using their IDs. If task B depends on task A, B SHOULD message A directly.
|
|
44
|
-
{{/if}}
|
|
45
|
-
{{#if asyncEnabled}}
|
|
46
|
-
- If you run out of things to do and are genuinely blocked waiting for a subagent, use `job poll`. Use `job cancel` only for stalled work.
|
|
39
|
+
- Write shared project state ONCE to a `local://` file (e.g., `local://ctx.md`) and reference that URL in your assignments.
|
|
47
40
|
{{/if}}
|
|
48
41
|
|
|
49
42
|
# Format Contracts
|
|
@@ -250,6 +250,7 @@ import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool
|
|
|
250
250
|
type: "text",
|
|
251
251
|
};
|
|
252
252
|
import sideChannelNoToolsReminder from "../prompts/system/side-channel-no-tools.md" with { type: "text" };
|
|
253
|
+
import thinkingLoopRedirectTemplate from "../prompts/system/thinking-loop-redirect.md" with { type: "text" };
|
|
253
254
|
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
254
255
|
import ttsrToolReminderTemplate from "../prompts/system/ttsr-tool-reminder.md" with { type: "text" };
|
|
255
256
|
import unexpectedStopRetryTemplate from "../prompts/system/unexpected-stop-retry.md" with { type: "text" };
|
|
@@ -346,6 +347,9 @@ const SESSION_STOP_CONTINUATION_CAP = 8;
|
|
|
346
347
|
const GEMINI_HEADER_INTERRUPT_REASON = "Interrupted: emit a tool call instead of more planning";
|
|
347
348
|
/** `customType` for the hidden tool-call reminder injected after the interrupt. */
|
|
348
349
|
const GEMINI_TOOL_REMINDER_TYPE = "gemini-tool-call-reminder";
|
|
350
|
+
/** `customType` for the hidden redirect notice injected into a turn retried after a
|
|
351
|
+
* thinking/response loop. Steers the model off the repeated content; never displayed. */
|
|
352
|
+
const THINKING_LOOP_REDIRECT_TYPE = "thinking-loop-redirect";
|
|
349
353
|
|
|
350
354
|
// A side-channel assistant response is signed for the hidden prompt/history that
|
|
351
355
|
// produced it. If we persist that response under a different user turn, native
|
|
@@ -2882,10 +2886,10 @@ export class AgentSession {
|
|
|
2882
2886
|
* the mid-run-compaction planner can ask "is this turn message already on
|
|
2883
2887
|
* the branch?" in O(1) instead of re-walking the branch per check.
|
|
2884
2888
|
*
|
|
2885
|
-
* The
|
|
2886
|
-
*
|
|
2887
|
-
*
|
|
2888
|
-
*
|
|
2889
|
+
* The mid-run ordering check uses key identity alone: same-key content
|
|
2890
|
+
* variants are one logical message at this boundary, because otherwise a
|
|
2891
|
+
* display-side rewrite can make the assistant look missing after its tool
|
|
2892
|
+
* results have already persisted.
|
|
2889
2893
|
*
|
|
2890
2894
|
* Pre-#3629 the equivalent was `sessionManager.getBranch()` called twice
|
|
2891
2895
|
* per turn message, each call rebuilding the path via O(n²) `unshift` and
|
|
@@ -2893,17 +2897,14 @@ export class AgentSession {
|
|
|
2893
2897
|
* per `onTurnEnd` on a long session and the load-bearing source of the
|
|
2894
2898
|
* `ui.loop-blocked` warnings in the bug report.
|
|
2895
2899
|
*/
|
|
2896
|
-
#
|
|
2897
|
-
const
|
|
2900
|
+
#indexPersistedMessageKeys(): Set<string> {
|
|
2901
|
+
const keys = new Set<string>();
|
|
2898
2902
|
for (const entry of this.sessionManager.getBranch()) {
|
|
2899
2903
|
if (entry.type !== "message") continue;
|
|
2900
2904
|
const key = sessionMessagePersistenceKey(entry.message);
|
|
2901
|
-
if (key
|
|
2902
|
-
const existing = index.get(key);
|
|
2903
|
-
if (existing) existing.push(entry.message);
|
|
2904
|
-
else index.set(key, [entry.message]);
|
|
2905
|
+
if (key !== undefined) keys.add(key);
|
|
2905
2906
|
}
|
|
2906
|
-
return
|
|
2907
|
+
return keys;
|
|
2907
2908
|
}
|
|
2908
2909
|
|
|
2909
2910
|
/**
|
|
@@ -3003,17 +3004,17 @@ export class AgentSession {
|
|
|
3003
3004
|
// JSON-compared every entry per turn message, which on long sessions
|
|
3004
3005
|
// turned each `onTurnEnd` into a seconds-long sync block (the
|
|
3005
3006
|
// `ui.loop-blocked` warnings tagged `subagent:*` in the bug report).
|
|
3006
|
-
const
|
|
3007
|
+
const branchKeys = this.#indexPersistedMessageKeys();
|
|
3007
3008
|
const turnKeys = turnMessages.map(sessionMessagePersistenceKey);
|
|
3008
3009
|
const persistedKeys = new Set<string>();
|
|
3009
3010
|
for (let index = 0; index < turnMessages.length; index++) {
|
|
3010
3011
|
const key = turnKeys[index];
|
|
3011
3012
|
if (key === undefined) continue;
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
//
|
|
3015
|
-
//
|
|
3016
|
-
if (
|
|
3013
|
+
// Mid-run ordering is keyed by logical identity. A persisted display
|
|
3014
|
+
// variant (for example, redacted/deobfuscated content) must still count;
|
|
3015
|
+
// otherwise the assistant can look missing while later tool results are
|
|
3016
|
+
// present, producing a false out-of-order skip.
|
|
3017
|
+
if (branchKeys.has(key)) {
|
|
3017
3018
|
persistedKeys.add(key);
|
|
3018
3019
|
}
|
|
3019
3020
|
}
|
|
@@ -8087,7 +8088,7 @@ export class AgentSession {
|
|
|
8087
8088
|
*/
|
|
8088
8089
|
async setModelTemporary(
|
|
8089
8090
|
model: Model,
|
|
8090
|
-
thinkingLevel?:
|
|
8091
|
+
thinkingLevel?: ConfiguredThinkingLevel,
|
|
8091
8092
|
options?: { ephemeral?: boolean },
|
|
8092
8093
|
): Promise<void> {
|
|
8093
8094
|
const previousEditMode = this.#resolveActiveEditMode();
|
|
@@ -8968,6 +8969,22 @@ export class AgentSession {
|
|
|
8968
8969
|
...(snapcompactShapeSetting === "auto" ? {} : { shape }),
|
|
8969
8970
|
maxFrames,
|
|
8970
8971
|
});
|
|
8972
|
+
const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
|
|
8973
|
+
if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
|
|
8974
|
+
logger.warn("Snapcompact exceeded the per-request frame payload budget", {
|
|
8975
|
+
model: this.model?.id,
|
|
8976
|
+
framePayloadBytes,
|
|
8977
|
+
budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
|
|
8978
|
+
});
|
|
8979
|
+
this.emitNotice(
|
|
8980
|
+
"warning",
|
|
8981
|
+
"snapcompact produced too much standing image payload. No LLM fallback was attempted.",
|
|
8982
|
+
"compaction",
|
|
8983
|
+
);
|
|
8984
|
+
throw new Error(
|
|
8985
|
+
"snapcompact cannot run locally: standing image payload exceeds the per-request budget.",
|
|
8986
|
+
);
|
|
8987
|
+
}
|
|
8971
8988
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
8972
8989
|
const budget =
|
|
8973
8990
|
ctxWindow > 0
|
|
@@ -10930,7 +10947,7 @@ export class AgentSession {
|
|
|
10930
10947
|
*/
|
|
10931
10948
|
#computeSnapcompactMaxFrames(preparation: CompactionPreparation, settings: CompactionSettings): number {
|
|
10932
10949
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
10933
|
-
if (ctxWindow <= 0) return snapcompact.MAX_FRAMES_DEFAULT;
|
|
10950
|
+
if (ctxWindow <= 0) return Math.min(snapcompact.MAX_FRAMES_DEFAULT, snapcompact.maxFramesForDataBudget());
|
|
10934
10951
|
const reserve = effectiveReserveTokens(ctxWindow, settings);
|
|
10935
10952
|
let baseTokens = computeNonMessageTokens(this);
|
|
10936
10953
|
for (const message of preparation.recentMessages) {
|
|
@@ -10969,7 +10986,16 @@ export class AgentSession {
|
|
|
10969
10986
|
const capReserve = textEdgeTokens + SUMMARY_TEMPLATE_TOKENS;
|
|
10970
10987
|
const frameBudget = totalBudget - baseTokens - capReserve;
|
|
10971
10988
|
if (frameBudget < snapcompact.FRAME_TOKEN_ESTIMATE) return 1;
|
|
10972
|
-
return Math.min(
|
|
10989
|
+
return Math.min(
|
|
10990
|
+
Math.floor(frameBudget / snapcompact.FRAME_TOKEN_ESTIMATE),
|
|
10991
|
+
snapcompact.MAX_FRAMES_DEFAULT,
|
|
10992
|
+
snapcompact.maxFramesForDataBudget(),
|
|
10993
|
+
);
|
|
10994
|
+
}
|
|
10995
|
+
|
|
10996
|
+
#snapcompactFramePayloadBytes(result: snapcompact.CompactionResult): number {
|
|
10997
|
+
const archive = snapcompact.getPreservedArchive(result.preserveData);
|
|
10998
|
+
return archive ? snapcompact.frameDataBytes(archive.frames) : 0;
|
|
10973
10999
|
}
|
|
10974
11000
|
|
|
10975
11001
|
/**
|
|
@@ -10982,7 +11008,9 @@ export class AgentSession {
|
|
|
10982
11008
|
*/
|
|
10983
11009
|
#projectSnapcompactContextTokens(preparation: CompactionPreparation, result: snapcompact.CompactionResult): number {
|
|
10984
11010
|
const archive = snapcompact.getPreservedArchive(result.preserveData);
|
|
10985
|
-
const blocks = archive
|
|
11011
|
+
const blocks = archive
|
|
11012
|
+
? snapcompact.historyBlocks(archive, { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET })
|
|
11013
|
+
: undefined;
|
|
10986
11014
|
const summaryMessage = createCompactionSummaryMessage(
|
|
10987
11015
|
result.summary,
|
|
10988
11016
|
result.tokensBefore,
|
|
@@ -11073,6 +11101,52 @@ export class AgentSession {
|
|
|
11073
11101
|
return residualTokens <= fitBudget;
|
|
11074
11102
|
}
|
|
11075
11103
|
|
|
11104
|
+
/**
|
|
11105
|
+
* Last-resort reducer when {@link #runAutoCompaction} would otherwise dead-end.
|
|
11106
|
+
* The summarizer cut at the only available turn boundary, but the kept tail is
|
|
11107
|
+
* still over the recovery band because a single recent turn (a large
|
|
11108
|
+
* tool-result, a heavy fenced/XML block) is itself bigger than the band and
|
|
11109
|
+
* `findCutPoint` cannot cut inside one message. `shake("elide")` reaches INSIDE
|
|
11110
|
+
* that tail — it offloads heavy tool-result / block content to one
|
|
11111
|
+
* `artifact://` blob and leaves a recoverable placeholder — so residual context
|
|
11112
|
+
* genuinely drops instead of the guard pausing maintenance and looping the
|
|
11113
|
+
* warning. Without it the guard would pause/warn here; with it the caller
|
|
11114
|
+
* re-tests its progress predicate after the elide pass and only falls through
|
|
11115
|
+
* to the warning when residual stays over.
|
|
11116
|
+
*
|
|
11117
|
+
* Image-only tails are out of scope: `collectShakeRegions` skips image-only
|
|
11118
|
+
* tool results and user-message images aren't counted by the local estimate
|
|
11119
|
+
* that gates the dead-end, so those still surface the warning (remedy:
|
|
11120
|
+
* `/shake images`).
|
|
11121
|
+
*
|
|
11122
|
+
* Returns the elide {@link ShakeResult} when something was offloaded (so the
|
|
11123
|
+
* caller can re-test and report), or `undefined` when nothing was eligible or
|
|
11124
|
+
* the pass aborted/failed.
|
|
11125
|
+
*/
|
|
11126
|
+
async #tryShakeRescueForDeadEnd(signal: AbortSignal): Promise<ShakeResult | undefined> {
|
|
11127
|
+
if (signal.aborted) return undefined;
|
|
11128
|
+
try {
|
|
11129
|
+
const result = await this.shake("elide", { signal });
|
|
11130
|
+
return result.toolResultsDropped + result.blocksDropped > 0 ? result : undefined;
|
|
11131
|
+
} catch (error) {
|
|
11132
|
+
logger.warn("Dead-end shake rescue failed", {
|
|
11133
|
+
error: error instanceof Error ? error.message : String(error),
|
|
11134
|
+
});
|
|
11135
|
+
return undefined;
|
|
11136
|
+
}
|
|
11137
|
+
}
|
|
11138
|
+
|
|
11139
|
+
/** Notice describing a successful dead-end elide rescue. */
|
|
11140
|
+
#emitShakeRescueNotice(result: ShakeResult): void {
|
|
11141
|
+
const elided = result.toolResultsDropped + result.blocksDropped;
|
|
11142
|
+
const sink = result.artifactId ? "an artifact" : "placeholders";
|
|
11143
|
+
this.emitNotice(
|
|
11144
|
+
"info",
|
|
11145
|
+
`Compaction dead-end recovery: elided ${elided} heavy block${elided === 1 ? "" : "s"} (~${result.tokensFreed.toLocaleString()} tokens) to ${sink} so maintenance could make progress.`,
|
|
11146
|
+
"compaction",
|
|
11147
|
+
);
|
|
11148
|
+
}
|
|
11149
|
+
|
|
11076
11150
|
/**
|
|
11077
11151
|
* Internal: Run auto-compaction with events.
|
|
11078
11152
|
*
|
|
@@ -11106,6 +11180,7 @@ export class AgentSession {
|
|
|
11106
11180
|
const shouldAutoContinue =
|
|
11107
11181
|
!suppressContinuation && options.autoContinue !== false && compactionSettings.autoContinue !== false;
|
|
11108
11182
|
const suppressHandoff = options.suppressHandoff === true;
|
|
11183
|
+
let fallbackFromShake = false;
|
|
11109
11184
|
// Shake runs inline (cheap, no remote LLM). On overflow recovery, if shake
|
|
11110
11185
|
// reclaims nothing we fall through to the summary-compaction body below so
|
|
11111
11186
|
// the oversized input still gets resolved.
|
|
@@ -11119,6 +11194,7 @@ export class AgentSession {
|
|
|
11119
11194
|
suppressContinuation,
|
|
11120
11195
|
);
|
|
11121
11196
|
if (outcome !== "fallback") return outcome;
|
|
11197
|
+
fallbackFromShake = true;
|
|
11122
11198
|
}
|
|
11123
11199
|
// "overflow" and "incomplete" force inline execution because they are recovery
|
|
11124
11200
|
// paths the caller wants resolved before scheduling the next turn. "idle" is
|
|
@@ -11347,6 +11423,17 @@ export class AgentSession {
|
|
|
11347
11423
|
...(shapeSetting === "auto" ? {} : { shape }),
|
|
11348
11424
|
maxFrames,
|
|
11349
11425
|
});
|
|
11426
|
+
const framePayloadBytes = this.#snapcompactFramePayloadBytes(snapcompactResult);
|
|
11427
|
+
if (framePayloadBytes > snapcompact.FRAME_DATA_BYTES_BUDGET) {
|
|
11428
|
+
logger.warn("Snapcompact exceeded the per-request frame payload budget", {
|
|
11429
|
+
model: this.model?.id,
|
|
11430
|
+
framePayloadBytes,
|
|
11431
|
+
budget: snapcompact.FRAME_DATA_BYTES_BUDGET,
|
|
11432
|
+
});
|
|
11433
|
+
snapcompactBlocker =
|
|
11434
|
+
"snapcompact produced too much standing image payload; using context-full auto-compaction instead.";
|
|
11435
|
+
snapcompactResult = undefined;
|
|
11436
|
+
}
|
|
11350
11437
|
if (snapcompactResult) {
|
|
11351
11438
|
const ctxWindow = this.model?.contextWindow ?? 0;
|
|
11352
11439
|
const budget =
|
|
@@ -11606,7 +11693,15 @@ export class AgentSession {
|
|
|
11606
11693
|
// won't include) is excluded. Reusing the auto-continue recovery band
|
|
11607
11694
|
// here turned recoverable overflows into manual dead-ends (#3412 review),
|
|
11608
11695
|
// so use the looser fit budget.
|
|
11609
|
-
|
|
11696
|
+
let retryFits = this.#compactionCreatedRetryFit();
|
|
11697
|
+
if (!retryFits && !fallbackFromShake) {
|
|
11698
|
+
const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
|
|
11699
|
+
if (rescue && this.#compactionCreatedRetryFit()) {
|
|
11700
|
+
retryFits = true;
|
|
11701
|
+
this.#emitShakeRescueNotice(rescue);
|
|
11702
|
+
}
|
|
11703
|
+
}
|
|
11704
|
+
if (retryFits) {
|
|
11610
11705
|
this.#scheduleAgentContinue({ delayMs: 100, generation });
|
|
11611
11706
|
continuationScheduled = true;
|
|
11612
11707
|
} else {
|
|
@@ -11620,7 +11715,15 @@ export class AgentSession {
|
|
|
11620
11715
|
// when auto-continue is disabled, a no-headroom threshold pass must still
|
|
11621
11716
|
// block later automatic continuations (todo reminders/session_stop hooks)
|
|
11622
11717
|
// from re-entering the same oversized context.
|
|
11623
|
-
|
|
11718
|
+
let hasHeadroom = this.#compactionCreatedHeadroom();
|
|
11719
|
+
if (!hasHeadroom && !fallbackFromShake) {
|
|
11720
|
+
const rescue = await this.#tryShakeRescueForDeadEnd(autoCompactionSignal);
|
|
11721
|
+
if (rescue && this.#compactionCreatedHeadroom()) {
|
|
11722
|
+
hasHeadroom = true;
|
|
11723
|
+
this.#emitShakeRescueNotice(rescue);
|
|
11724
|
+
}
|
|
11725
|
+
}
|
|
11726
|
+
if (hasHeadroom) {
|
|
11624
11727
|
if (shouldAutoContinue) {
|
|
11625
11728
|
this.#scheduleAutoContinuePrompt(generation);
|
|
11626
11729
|
continuationScheduled = true;
|
|
@@ -11644,7 +11747,7 @@ export class AgentSession {
|
|
|
11644
11747
|
if (noProgressDeadEnd) {
|
|
11645
11748
|
this.emitNotice(
|
|
11646
11749
|
"warning",
|
|
11647
|
-
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further;
|
|
11750
|
+
"Compaction freed too little context to make progress — pausing automatic maintenance to avoid a compaction loop. The most recent turn alone is too large to reduce further; clear large tool output, run `/shake images` to drop attached images, or switch to a larger-context model.",
|
|
11648
11751
|
"compaction",
|
|
11649
11752
|
);
|
|
11650
11753
|
}
|
|
@@ -12480,6 +12583,11 @@ export class AgentSession {
|
|
|
12480
12583
|
// Remove the failed assistant message from active context before retrying.
|
|
12481
12584
|
this.#removeAssistantMessageFromActiveContext(message);
|
|
12482
12585
|
|
|
12586
|
+
// A thinking/response loop retried into identical context loops again. Inject a
|
|
12587
|
+
// hidden redirect so the retried turn sees a directive to break the repeated
|
|
12588
|
+
// pattern instead of re-sampling the same stalled reasoning.
|
|
12589
|
+
this.#maybeInjectThinkingLoopRedirect(id);
|
|
12590
|
+
|
|
12483
12591
|
// Wait with exponential backoff (abortable).
|
|
12484
12592
|
const retryAbortController = new AbortController();
|
|
12485
12593
|
this.#retryAbortController?.abort();
|
|
@@ -12513,6 +12621,35 @@ export class AgentSession {
|
|
|
12513
12621
|
return true;
|
|
12514
12622
|
}
|
|
12515
12623
|
|
|
12624
|
+
/**
|
|
12625
|
+
* Inject a hidden redirect notice when a thinking/response loop is being retried, so
|
|
12626
|
+
* the retried turn carries an instruction to break the repeated pattern instead of
|
|
12627
|
+
* re-sampling the same stalled context. Injected on every {@link AIError.Flag.ThinkingLoop}
|
|
12628
|
+
* retry (the failed assistant is dropped each attempt, so the notice does not accumulate
|
|
12629
|
+
* unboundedly). No-op unless `id` carries the ThinkingLoop flag and the loop guard is
|
|
12630
|
+
* enabled. The notice is generic on purpose — the detector's detail can quote raw model
|
|
12631
|
+
* text, which must not be interpolated into a higher-priority developer message.
|
|
12632
|
+
*/
|
|
12633
|
+
#maybeInjectThinkingLoopRedirect(id: number): void {
|
|
12634
|
+
if (!AIError.is(id, AIError.Flag.ThinkingLoop)) return;
|
|
12635
|
+
if (this.settings.get("model.loopGuard.enabled") !== true) return;
|
|
12636
|
+
this.agent.appendMessage({
|
|
12637
|
+
role: "custom",
|
|
12638
|
+
customType: THINKING_LOOP_REDIRECT_TYPE,
|
|
12639
|
+
content: thinkingLoopRedirectTemplate,
|
|
12640
|
+
display: false,
|
|
12641
|
+
attribution: "agent",
|
|
12642
|
+
timestamp: Date.now(),
|
|
12643
|
+
});
|
|
12644
|
+
this.sessionManager.appendCustomMessageEntry(
|
|
12645
|
+
THINKING_LOOP_REDIRECT_TYPE,
|
|
12646
|
+
thinkingLoopRedirectTemplate,
|
|
12647
|
+
false,
|
|
12648
|
+
undefined,
|
|
12649
|
+
"agent",
|
|
12650
|
+
);
|
|
12651
|
+
}
|
|
12652
|
+
|
|
12516
12653
|
/**
|
|
12517
12654
|
* Cancel in-progress retry.
|
|
12518
12655
|
*/
|
|
@@ -77,6 +77,17 @@ export interface BuildSessionContextOptions {
|
|
|
77
77
|
* If leafId is provided, walks from that entry to root.
|
|
78
78
|
* Handles compaction and branch summaries along the path.
|
|
79
79
|
*/
|
|
80
|
+
function snapcompactHistoryBlocksForContext(
|
|
81
|
+
archive: snapcompact.Archive | undefined,
|
|
82
|
+
options: BuildSessionContextOptions | undefined,
|
|
83
|
+
) {
|
|
84
|
+
if (!archive) return undefined;
|
|
85
|
+
return snapcompact.historyBlocks(
|
|
86
|
+
archive,
|
|
87
|
+
options?.transcript ? undefined : { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
export function buildSessionContext(
|
|
81
92
|
entries: SessionEntry[],
|
|
82
93
|
leafId?: string | null,
|
|
@@ -273,7 +284,7 @@ export function buildSessionContext(
|
|
|
273
284
|
entry.shortSummary,
|
|
274
285
|
undefined,
|
|
275
286
|
undefined,
|
|
276
|
-
snapcompactArchive
|
|
287
|
+
snapcompactHistoryBlocksForContext(snapcompactArchive, options),
|
|
277
288
|
),
|
|
278
289
|
);
|
|
279
290
|
} else {
|
|
@@ -307,7 +318,7 @@ export function buildSessionContext(
|
|
|
307
318
|
compaction.shortSummary,
|
|
308
319
|
providerPayload,
|
|
309
320
|
undefined,
|
|
310
|
-
snapcompactArchive
|
|
321
|
+
snapcompactHistoryBlocksForContext(snapcompactArchive, options),
|
|
311
322
|
),
|
|
312
323
|
);
|
|
313
324
|
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Settings-aware stream wrapper shared by the main agent (sdk.ts) and the
|
|
3
3
|
* advisor agent (AgentSession.#buildAdvisorRuntime).
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* verbosity, stream watchdog budgets, per-provider in-flight caps, and the loop
|
|
6
|
+
* guard out of `Settings`
|
|
7
7
|
* per request, layering them onto whatever options the caller passed. Before
|
|
8
8
|
* this helper existed, advisor turns called bare `streamSimple` while the main
|
|
9
9
|
* turn went through an inline closure that read these settings — so an advisor on
|
|
@@ -14,6 +14,12 @@ import type { StreamFn } from "@oh-my-pi/pi-agent-core";
|
|
|
14
14
|
import { type SimpleStreamOptions, streamSimple } from "@oh-my-pi/pi-ai";
|
|
15
15
|
import { type Settings, validateProviderMaxInFlightRequests } from "../config/settings";
|
|
16
16
|
|
|
17
|
+
function timeoutSecondsToMs(value: number): number | undefined {
|
|
18
|
+
if (!Number.isFinite(value) || value < 0) return undefined;
|
|
19
|
+
if (value === 0) return 0;
|
|
20
|
+
return Math.max(1, Math.trunc(value * 1000));
|
|
21
|
+
}
|
|
22
|
+
|
|
17
23
|
/**
|
|
18
24
|
* Build a {@link StreamFn} that reads provider routing/guard settings from
|
|
19
25
|
* `settings` per call and forwards to `base` (defaults to `streamSimple`).
|
|
@@ -30,11 +36,15 @@ export function createSettingsAwareStreamFn(settings: Settings, base: StreamFn =
|
|
|
30
36
|
model.api === "openai-codex-responses" || model.api === "openai-responses"
|
|
31
37
|
? settings.get("textVerbosity")
|
|
32
38
|
: undefined;
|
|
39
|
+
const streamFirstEventTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamFirstEventTimeoutSeconds"));
|
|
40
|
+
const streamIdleTimeoutMs = timeoutSecondsToMs(settings.get("providers.streamIdleTimeoutSeconds"));
|
|
33
41
|
const merged: SimpleStreamOptions = {
|
|
34
42
|
...streamOptions,
|
|
35
43
|
openrouterVariant: streamOptions?.openrouterVariant ?? openrouterVariant,
|
|
36
44
|
antigravityEndpointMode: streamOptions?.antigravityEndpointMode ?? antigravityEndpointMode,
|
|
37
45
|
textVerbosity: streamOptions?.textVerbosity ?? textVerbosity,
|
|
46
|
+
streamFirstEventTimeoutMs: streamOptions?.streamFirstEventTimeoutMs ?? streamFirstEventTimeoutMs,
|
|
47
|
+
streamIdleTimeoutMs: streamOptions?.streamIdleTimeoutMs ?? streamIdleTimeoutMs,
|
|
38
48
|
maxInFlightRequests: validateProviderMaxInFlightRequests(
|
|
39
49
|
streamOptions?.maxInFlightRequests ?? settings.get("providers.maxInFlightRequests"),
|
|
40
50
|
),
|
package/src/stt/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
startStreamingRecording,
|
|
16
16
|
verifyRecordingFile,
|
|
17
17
|
} from "./recorder";
|
|
18
|
+
import { evaluateSubmitTrigger } from "./submit-trigger";
|
|
18
19
|
import { transcribe } from "./transcriber";
|
|
19
20
|
|
|
20
21
|
export type SttState = "idle" | "recording" | "transcribing";
|
|
@@ -33,6 +34,8 @@ interface Editor {
|
|
|
33
34
|
setVolatileText(text: string): void;
|
|
34
35
|
clearVolatileText(): void;
|
|
35
36
|
commitVolatileText(text: string): void;
|
|
37
|
+
submit(): void;
|
|
38
|
+
deleteBeforeCursor(count: number): void;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export class STTController {
|
|
@@ -53,6 +56,7 @@ export class STTController {
|
|
|
53
56
|
#streamEditor: Editor | null = null;
|
|
54
57
|
#streamCommitted = false;
|
|
55
58
|
#streamAbort: AbortController | null = null;
|
|
59
|
+
#streamUtterance = "";
|
|
56
60
|
|
|
57
61
|
get state(): SttState {
|
|
58
62
|
return this.#state;
|
|
@@ -190,6 +194,7 @@ export class STTController {
|
|
|
190
194
|
const language = settings.get("stt.language") as string | undefined;
|
|
191
195
|
this.#streamEditor = editor;
|
|
192
196
|
this.#streamCommitted = false;
|
|
197
|
+
this.#streamUtterance = "";
|
|
193
198
|
this.#streamAbort = new AbortController();
|
|
194
199
|
const stream = sttClient.startStream(modelKey, {
|
|
195
200
|
language: language || undefined,
|
|
@@ -205,6 +210,7 @@ export class STTController {
|
|
|
205
210
|
if (prefixed) {
|
|
206
211
|
this.#streamEditor?.commitVolatileText(prefixed);
|
|
207
212
|
this.#streamCommitted = true;
|
|
213
|
+
this.#streamUtterance += prefixed;
|
|
208
214
|
} else {
|
|
209
215
|
this.#streamEditor?.clearVolatileText();
|
|
210
216
|
}
|
|
@@ -266,13 +272,27 @@ export class STTController {
|
|
|
266
272
|
return;
|
|
267
273
|
}
|
|
268
274
|
if (!this.#streamCommitted && finalText) {
|
|
269
|
-
this.#
|
|
275
|
+
const prefixed = this.#prefixed(finalText);
|
|
276
|
+
this.#streamEditor?.commitVolatileText(prefixed);
|
|
270
277
|
this.#streamCommitted = true;
|
|
278
|
+
this.#streamUtterance = prefixed;
|
|
271
279
|
} else {
|
|
272
280
|
this.#streamEditor?.clearVolatileText();
|
|
273
281
|
}
|
|
274
282
|
options.requestRender?.();
|
|
275
283
|
if (!failed) options.showStatus(this.#streamCommitted ? "" : "No speech detected.");
|
|
284
|
+
|
|
285
|
+
if (this.#streamCommitted && !failed && this.#streamEditor) {
|
|
286
|
+
const trigger = settings.get("stt.submitTrigger");
|
|
287
|
+
const { submit, trimTrailing } = evaluateSubmitTrigger(this.#streamUtterance, trigger);
|
|
288
|
+
if (trimTrailing > 0) {
|
|
289
|
+
this.#streamEditor.deleteBeforeCursor(trimTrailing);
|
|
290
|
+
}
|
|
291
|
+
if (submit) {
|
|
292
|
+
this.#streamEditor.submit();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
276
296
|
this.#cleanupStream();
|
|
277
297
|
this.#setState("idle", options);
|
|
278
298
|
}
|
|
@@ -283,6 +303,7 @@ export class STTController {
|
|
|
283
303
|
this.#streamEditor = null;
|
|
284
304
|
this.#streamCommitted = false;
|
|
285
305
|
this.#streamAbort = null;
|
|
306
|
+
this.#streamUtterance = "";
|
|
286
307
|
}
|
|
287
308
|
|
|
288
309
|
// ── Batch (single-shot) ─────────────────────────────────────────
|
|
@@ -327,8 +348,16 @@ export class STTController {
|
|
|
327
348
|
this.#transcriptionAbort = null;
|
|
328
349
|
if (this.#disposed) return;
|
|
329
350
|
if (text.length > 0) {
|
|
330
|
-
|
|
351
|
+
const trigger = settings.get("stt.submitTrigger");
|
|
352
|
+
const { submit, trimTrailing } = evaluateSubmitTrigger(text, trigger);
|
|
353
|
+
const textToInsert = trimTrailing > 0 ? text.slice(0, -trimTrailing) : text;
|
|
354
|
+
if (textToInsert.length > 0) {
|
|
355
|
+
editor.insertText(textToInsert);
|
|
356
|
+
}
|
|
331
357
|
options.showStatus("");
|
|
358
|
+
if (submit) {
|
|
359
|
+
editor.submit();
|
|
360
|
+
}
|
|
332
361
|
} else {
|
|
333
362
|
options.showStatus("No speech detected.");
|
|
334
363
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTS/STT Submit Trigger options and evaluation logic.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const STT_SUBMIT_TRIGGER_VALUES = ["never", "release", "release-complete", "say-submit"] as const;
|
|
6
|
+
|
|
7
|
+
export type SttSubmitTrigger = (typeof STT_SUBMIT_TRIGGER_VALUES)[number];
|
|
8
|
+
|
|
9
|
+
export const STT_SUBMIT_TRIGGER_OPTIONS = [
|
|
10
|
+
{
|
|
11
|
+
value: "never",
|
|
12
|
+
label: "Never",
|
|
13
|
+
description: "Never automatically submit; insert dictation and remain in editor.",
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
value: "release",
|
|
17
|
+
label: "Release",
|
|
18
|
+
description: "Submit on release if the utterance has 2+ words to avoid accidental sends.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
value: "release-complete",
|
|
22
|
+
label: "Release with complete sentence",
|
|
23
|
+
description: "Submit on release if the utterance ends with sentence-terminal punctuation (. ? ! etc.).",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
value: "say-submit",
|
|
27
|
+
label: "When I Say Submit",
|
|
28
|
+
description: "Submit if the utterance ends with a word containing 'submit' (strips that word before submitting).",
|
|
29
|
+
},
|
|
30
|
+
] satisfies ReadonlyArray<{ value: SttSubmitTrigger; label: string; description: string }>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Evaluate the submit trigger against a transcribed utterance.
|
|
34
|
+
* Returns whether to submit, and the number of characters to trim from the end of the utterance.
|
|
35
|
+
*/
|
|
36
|
+
export function evaluateSubmitTrigger(
|
|
37
|
+
utterance: string,
|
|
38
|
+
trigger: SttSubmitTrigger,
|
|
39
|
+
): { submit: boolean; trimTrailing: number } {
|
|
40
|
+
const trimmed = utterance.trim();
|
|
41
|
+
if (!trimmed) {
|
|
42
|
+
return { submit: false, trimTrailing: 0 };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (trigger === "never") {
|
|
46
|
+
return { submit: false, trimTrailing: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (trigger === "release") {
|
|
50
|
+
// Split by whitespace and count words
|
|
51
|
+
const words = trimmed.split(/\s+/).filter(Boolean);
|
|
52
|
+
const submit = words.length >= 2;
|
|
53
|
+
return { submit, trimTrailing: 0 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (trigger === "release-complete") {
|
|
57
|
+
// Matches typical sentence terminators: . ? ! ... or full-width equivalents, optionally followed by space
|
|
58
|
+
const hasTerminalPunctuation = /[.?!…。?!]\s*$/.test(trimmed);
|
|
59
|
+
return { submit: hasTerminalPunctuation, trimTrailing: 0 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (trigger === "say-submit") {
|
|
63
|
+
// Matches space followed by any word containing "submit" (case-insensitive), optionally followed by punctuation/spaces
|
|
64
|
+
// Also handles the case where "submit" is the only word in the utterance (no leading space)
|
|
65
|
+
const match = utterance.match(/(?:^|\s+)(\S*submit\S*)[.?!…。?!]*\s*$/i);
|
|
66
|
+
if (match && match.index !== undefined) {
|
|
67
|
+
const trimTrailing = utterance.length - match.index;
|
|
68
|
+
return { submit: true, trimTrailing };
|
|
69
|
+
}
|
|
70
|
+
return { submit: false, trimTrailing: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { submit: false, trimTrailing: 0 };
|
|
74
|
+
}
|