@booplex/bpx-consult 0.1.0
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 +16 -0
- package/LICENSE +21 -0
- package/README.md +175 -0
- package/index.ts +112 -0
- package/package.json +54 -0
- package/prompts/advisor-system.txt +28 -0
- package/src/advisor.ts +137 -0
- package/src/cli-backend.ts +256 -0
- package/src/config.ts +422 -0
- package/src/consensus.ts +173 -0
- package/src/context-engine.ts +395 -0
- package/src/council.ts +429 -0
- package/src/debate.ts +292 -0
- package/src/messages.ts +49 -0
- package/src/personas.ts +163 -0
- package/src/solo.ts +205 -0
- package/src/timeout.ts +87 -0
- package/src/triggers.ts +190 -0
package/src/solo.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* solo — the default consult mode.
|
|
3
|
+
*
|
|
4
|
+
* One advisor model, one response. The rpiv-advisor experience, but routed
|
|
5
|
+
* through the context engine so it never overflows the advisor's window.
|
|
6
|
+
*
|
|
7
|
+
* Flow:
|
|
8
|
+
* config → resolve solo model → build compacted session context → re-fit to
|
|
9
|
+
* the advisor's window (context-engine) → callAdvisor → return as tool result
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
13
|
+
import {
|
|
14
|
+
type AgentToolResult,
|
|
15
|
+
type AgentToolUpdateCallback,
|
|
16
|
+
type ExtensionContext,
|
|
17
|
+
buildSessionContext,
|
|
18
|
+
convertToLlm,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import { callAdvisor, resolveAdvisor } from "./advisor.js";
|
|
24
|
+
import { buildConsultContext, type ContextBudget } from "./context-engine.js";
|
|
25
|
+
import type { BpxConsultConfig } from "./config.js";
|
|
26
|
+
import { resolveBackend } from "./config.js";
|
|
27
|
+
import { callCliAdvisor } from "./cli-backend.js";
|
|
28
|
+
import {
|
|
29
|
+
ERR_ABORTED_DETAIL,
|
|
30
|
+
ERR_CALL_ABORTED,
|
|
31
|
+
ERR_EMPTY_RESPONSE,
|
|
32
|
+
ERR_EMPTY_RESPONSE_DETAIL,
|
|
33
|
+
ERR_NO_API_KEY,
|
|
34
|
+
ERR_NO_API_KEY_DETAIL,
|
|
35
|
+
ERR_NO_MODEL,
|
|
36
|
+
ERR_NO_MODEL_DETAIL,
|
|
37
|
+
errCallFailed,
|
|
38
|
+
errCallThrew,
|
|
39
|
+
errMisconfigured,
|
|
40
|
+
msgConsulting,
|
|
41
|
+
} from "./messages.js";
|
|
42
|
+
|
|
43
|
+
// Load the system prompt once, with a fallback so a missing/unreadable file
|
|
44
|
+
// never bricks the extension at import time. Bundled at prompts/advisor-system.txt.
|
|
45
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const ADVISOR_SYSTEM_PROMPT = (() => {
|
|
47
|
+
const fallback =
|
|
48
|
+
"You are an advisor model consulted mid-task by a coding executor. Return a PLAN, a CORRECTION, or a STOP signal. Be concrete, cite specifics, never call tools, never manufacture agreement.";
|
|
49
|
+
try {
|
|
50
|
+
return readFileSync(join(__dirname, "..", "prompts", "advisor-system.txt"), "utf-8").trim() || fallback;
|
|
51
|
+
} catch {
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
|
|
56
|
+
export interface SoloDetails {
|
|
57
|
+
advisorModel: string;
|
|
58
|
+
thinkingLevel?: string;
|
|
59
|
+
mode: "solo";
|
|
60
|
+
usage?: { input: number; output: number; total: number };
|
|
61
|
+
/** Estimated input tokens after the context engine re-fit. */
|
|
62
|
+
fittedTokens?: number;
|
|
63
|
+
/** Messages dropped by the sliding window, if any. */
|
|
64
|
+
omitted?: number;
|
|
65
|
+
stopReason?: string;
|
|
66
|
+
errorMessage?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function ok(text: string, details: SoloDetails): AgentToolResult<SoloDetails> {
|
|
70
|
+
return { content: [{ type: "text", text }], details };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function err(text: string, details: SoloDetails): AgentToolResult<SoloDetails> {
|
|
74
|
+
return { content: [{ type: "text", text }], details };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface ExecuteSoloInput {
|
|
78
|
+
ctx: ExtensionContext;
|
|
79
|
+
config: BpxConsultConfig;
|
|
80
|
+
signal: AbortSignal | undefined;
|
|
81
|
+
onUpdate: AgentToolUpdateCallback<SoloDetails> | undefined;
|
|
82
|
+
/** Optional explicit question to inject at the tail of the context. */
|
|
83
|
+
question?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function executeSolo(input: ExecuteSoloInput): Promise<AgentToolResult<SoloDetails>> {
|
|
87
|
+
const { ctx, config, signal, onUpdate, question } = input;
|
|
88
|
+
|
|
89
|
+
const soloConfig = config.modes?.solo;
|
|
90
|
+
const advisor = resolveAdvisor(ctx, soloConfig?.model);
|
|
91
|
+
const thinkingLevel = soloConfig?.thinkingLevel;
|
|
92
|
+
|
|
93
|
+
if (!advisor) {
|
|
94
|
+
return err(ERR_NO_MODEL, { advisorModel: "(none)", mode: "solo", thinkingLevel, errorMessage: ERR_NO_MODEL_DETAIL });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
onUpdate?.({
|
|
98
|
+
content: [{ type: "text", text: msgConsulting(advisor.label) }],
|
|
99
|
+
details: { advisorModel: advisor.label, thinkingLevel, mode: "solo" },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// 1. Pull Pi's already-compacted session context for the active branch.
|
|
103
|
+
const { messages: sessionMessages } = buildSessionContext(
|
|
104
|
+
ctx.sessionManager.getEntries(),
|
|
105
|
+
ctx.sessionManager.getLeafId(),
|
|
106
|
+
);
|
|
107
|
+
const branchMessages: Message[] = convertToLlm(sessionMessages);
|
|
108
|
+
|
|
109
|
+
// 2. Re-fit to THIS advisor's window. This is the §P fix.
|
|
110
|
+
const contextBudget = config.contextBudget as ContextBudget;
|
|
111
|
+
// terse: cap the response hard so gut-check gets a short read, not an essay.
|
|
112
|
+
// Honored when gut-check merges its config into solo (modes.gutCheck.terse).
|
|
113
|
+
const maxTokens = soloConfig?.terse ? Math.min(1024, contextBudget.responseReserveTokens) : contextBudget.responseReserveTokens;
|
|
114
|
+
const advisorWindow = advisor.model.contextWindow;
|
|
115
|
+
const directive = question?.trim()
|
|
116
|
+
? `Specific question from the executor: ${question.trim()}`
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
const fit = buildConsultContext({
|
|
120
|
+
sessionMessages: branchMessages,
|
|
121
|
+
advisorContextWindow: advisorWindow,
|
|
122
|
+
budget: contextBudget,
|
|
123
|
+
directive,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
// Backend dispatch: if the solo model has a CLI backend configured, route
|
|
128
|
+
// to the async subprocess path (spawn the CLI, pipe the fitted context to
|
|
129
|
+
// stdin, parse the reply). Otherwise inline completeSimple. The fitted
|
|
130
|
+
// context is reused either way — §C ran once, both backends get the same
|
|
131
|
+
// window-safe payload. CLI uses spawn (non-blocking) so a CLI-backed council
|
|
132
|
+
// member can run parallel to an inline one (the whole point of async).
|
|
133
|
+
const backend = resolveBackend(config, soloConfig?.model);
|
|
134
|
+
let text: string;
|
|
135
|
+
let usage: { input: number; output: number; total: number } | undefined;
|
|
136
|
+
let stopReason: string;
|
|
137
|
+
let errorMessage: string | undefined;
|
|
138
|
+
|
|
139
|
+
if (backend?.type === "cli") {
|
|
140
|
+
const cliResult = await callCliAdvisor({
|
|
141
|
+
systemPrompt: ADVISOR_SYSTEM_PROMPT,
|
|
142
|
+
messages: fit.messages,
|
|
143
|
+
backend: { type: "cli", command: backend.command, args: backend.args, timeoutMs: backend.timeoutMs },
|
|
144
|
+
signal,
|
|
145
|
+
cwd: ctx.cwd,
|
|
146
|
+
});
|
|
147
|
+
text = cliResult.text;
|
|
148
|
+
usage = undefined; // CLIs don't report token usage
|
|
149
|
+
stopReason = cliResult.text ? "stop" : cliResult.timedOut ? "aborted" : "error";
|
|
150
|
+
errorMessage = cliResult.errorMessage;
|
|
151
|
+
} else {
|
|
152
|
+
const result = await callAdvisor({
|
|
153
|
+
ctx,
|
|
154
|
+
advisor,
|
|
155
|
+
systemPrompt: ADVISOR_SYSTEM_PROMPT,
|
|
156
|
+
messages: fit.messages,
|
|
157
|
+
thinkingLevel,
|
|
158
|
+
signal,
|
|
159
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
160
|
+
maxTokens,
|
|
161
|
+
});
|
|
162
|
+
text = result.text;
|
|
163
|
+
usage = result.usage;
|
|
164
|
+
stopReason = result.stopReason;
|
|
165
|
+
errorMessage = result.errorMessage;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const baseDetails: SoloDetails = {
|
|
169
|
+
advisorModel: advisor.label,
|
|
170
|
+
thinkingLevel,
|
|
171
|
+
mode: "solo",
|
|
172
|
+
usage,
|
|
173
|
+
fittedTokens: fit.estimatedTokens,
|
|
174
|
+
omitted: fit.omittedCount,
|
|
175
|
+
stopReason,
|
|
176
|
+
errorMessage,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (stopReason === "aborted") {
|
|
180
|
+
return err(ERR_CALL_ABORTED, { ...baseDetails, errorMessage: errorMessage ?? ERR_ABORTED_DETAIL });
|
|
181
|
+
}
|
|
182
|
+
if (stopReason === "error") {
|
|
183
|
+
return err(errCallFailed(errorMessage), baseDetails);
|
|
184
|
+
}
|
|
185
|
+
if (!text) {
|
|
186
|
+
return err(ERR_EMPTY_RESPONSE, { ...baseDetails, errorMessage: ERR_EMPTY_RESPONSE_DETAIL });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return ok(text, baseDetails);
|
|
190
|
+
} catch (e) {
|
|
191
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
192
|
+
return err(errCallThrew(message), {
|
|
193
|
+
advisorModel: advisor.label,
|
|
194
|
+
thinkingLevel,
|
|
195
|
+
mode: "solo",
|
|
196
|
+
fittedTokens: fit.estimatedTokens,
|
|
197
|
+
omitted: fit.omittedCount,
|
|
198
|
+
errorMessage: message,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Re-export the auth-error helpers so index.ts can use them without importing
|
|
204
|
+
// from two places. (kept for the registration layer's error paths.)
|
|
205
|
+
export { ERR_NO_API_KEY, ERR_NO_API_KEY_DETAIL, errMisconfigured };
|
package/src/timeout.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timeout — shared wall-clock budget for consult paths.
|
|
3
|
+
*
|
|
4
|
+
* Two consult paths need a wall-clock cap that the call itself can't provide:
|
|
5
|
+
* - debate: sequential rounds, total latency = sum-of-rounds, can hang
|
|
6
|
+
* mid-round with no human to interrupt (consult() is executor-callable →
|
|
7
|
+
* autonomous). The last unprotected path after council (per-member abort)
|
|
8
|
+
* and CLI (resolveShellTimeoutMs).
|
|
9
|
+
* - cli: subprocess timeout via pi.exec's own `timeout` option, but we wrap
|
|
10
|
+
* it here so the budget lives in one place.
|
|
11
|
+
*
|
|
12
|
+
* The helper races the operation against a timer that fires an AbortController
|
|
13
|
+
* — the same controller whose signal propagates into callAdvisor / pi.exec, so
|
|
14
|
+
* a timeout aborts the in-flight work cleanly rather than leaving it dangling.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run `fn` with a wall-clock timeout. Returns the fn's result, or an error
|
|
19
|
+
* result if the timeout fired first.
|
|
20
|
+
*
|
|
21
|
+
* `parentSignal` (e.g. ctx.signal — user abort) is linked to the controller so
|
|
22
|
+
* a user-initiated abort still propagates; the timeout is an independent second
|
|
23
|
+
* way to fire the same controller.
|
|
24
|
+
*
|
|
25
|
+
* `timeoutMs <= 0` disables the timeout (fn runs with just the parent signal).
|
|
26
|
+
*/
|
|
27
|
+
export async function withTimeout<T>(
|
|
28
|
+
timeoutMs: number,
|
|
29
|
+
parentSignal: AbortSignal | undefined,
|
|
30
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
31
|
+
): Promise<{ ok: true; value: T; timedOut: false } | { ok: false; timedOut: true; signal: AbortSignal } | { ok: false; timedOut: false; error: unknown }> {
|
|
32
|
+
// No timeout: just link the parent and run.
|
|
33
|
+
if (!timeoutMs || timeoutMs <= 0) {
|
|
34
|
+
const ctrl = linkController(parentSignal);
|
|
35
|
+
try {
|
|
36
|
+
return { ok: true, timedOut: false, value: await fn(ctrl.signal) };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return { ok: false, timedOut: false, error };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ctrl = linkController(parentSignal);
|
|
43
|
+
const timer = setTimeout(() => ctrl.abort(new TimeoutError(timeoutMs)), timeoutMs);
|
|
44
|
+
try {
|
|
45
|
+
const value = await fn(ctrl.signal);
|
|
46
|
+
return { ok: true, timedOut: false, value };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Distinguish timeout-abort from any other error. The controller's abort
|
|
49
|
+
// reason carries our TimeoutError; anything else is a real failure.
|
|
50
|
+
if (ctrl.signal.aborted && ctrl.signal.reason instanceof TimeoutError) {
|
|
51
|
+
return { ok: false, timedOut: true, signal: ctrl.signal };
|
|
52
|
+
}
|
|
53
|
+
return { ok: false, timedOut: false, error };
|
|
54
|
+
} finally {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Custom error so we can identify our own timeout vs a provider/network error. */
|
|
60
|
+
export class TimeoutError extends Error {
|
|
61
|
+
constructor(public readonly ms: number) {
|
|
62
|
+
super(`timed out after ${ms}ms`);
|
|
63
|
+
this.name = "TimeoutError";
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build an AbortController linked to a parent signal: if the parent aborts,
|
|
69
|
+
* this one aborts too (with the same reason). If the parent is already aborted,
|
|
70
|
+
* returns an already-aborted controller. Used by withTimeout and by council's
|
|
71
|
+
* per-member abort isolation (linkSignal re-exported from here for continuity).
|
|
72
|
+
*/
|
|
73
|
+
function linkController(parent: AbortSignal | undefined): AbortController {
|
|
74
|
+
const ctrl = new AbortController();
|
|
75
|
+
if (!parent) return ctrl;
|
|
76
|
+
if (parent.aborted) {
|
|
77
|
+
ctrl.abort(parent.reason);
|
|
78
|
+
return ctrl;
|
|
79
|
+
}
|
|
80
|
+
parent.addEventListener("abort", () => ctrl.abort(parent.reason), { once: true });
|
|
81
|
+
return ctrl;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Re-export for council/debate so the abort-linking pattern has one home. */
|
|
85
|
+
export function linkSignal(parent: AbortSignal | undefined): AbortSignal {
|
|
86
|
+
return linkController(parent).signal;
|
|
87
|
+
}
|
package/src/triggers.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* triggers — automatic consult invocation.
|
|
3
|
+
*
|
|
4
|
+
* Two auto-triggers (SPEC §T):
|
|
5
|
+
* - onDone: when the agent finishes a turn, review the work (off by default).
|
|
6
|
+
* - whenStuck: after N consecutive tool errors OR N identical tool calls
|
|
7
|
+
* (loop detection), review to get unstuck. Default N = 3.
|
|
8
|
+
*
|
|
9
|
+
* Plus manual (always available via the consult tool / /consult).
|
|
10
|
+
*
|
|
11
|
+
* Two traps both passed-and-bypassed (the things the reviewer is watching for):
|
|
12
|
+
*
|
|
13
|
+
* 1. DEADLOCK — never call session-control methods from an event handler.
|
|
14
|
+
* pi docs say they deadlock the event loop. The triggered consult runs the
|
|
15
|
+
* advisor call (safe — it's just completeSimple), then routes the result
|
|
16
|
+
* back via pi.sendUserMessage(text, { deliverAs: "steer" | "followUp" }),
|
|
17
|
+
* which is the documented non-deadlocking injection path.
|
|
18
|
+
*
|
|
19
|
+
* 2. SELF-TRIGGER — consult() is itself a tool, so it fires its own
|
|
20
|
+
* tool_result event. Without a guard, a triggered consult re-trips the
|
|
21
|
+
* loop detector (its own result looks like a repeated call). Two defenses:
|
|
22
|
+
* a. Skip the fingerprint/error tracking when toolName === "consult".
|
|
23
|
+
* b. autoRunning re-entrancy guard — while a triggered consult is in
|
|
24
|
+
* flight, every handler bails, so the consult's own events can't
|
|
25
|
+
* re-trigger anything.
|
|
26
|
+
* Counters also reset on before_agent_start (pi's per-prompt reset point).
|
|
27
|
+
*
|
|
28
|
+
* Trust gating: triggers never fire in untrusted projects (an untrusted repo
|
|
29
|
+
* must not be able to silently invoke the advisor / spend tokens).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
33
|
+
import type { BpxConsultConfig } from "./config.js";
|
|
34
|
+
import { loadConfig } from "./config.js";
|
|
35
|
+
import { executeSolo } from "./solo.js";
|
|
36
|
+
import { CONSULT_TOOL_NAME } from "./messages.js";
|
|
37
|
+
|
|
38
|
+
interface TriggerState {
|
|
39
|
+
stuckErrors: number;
|
|
40
|
+
lastFingerprint: string;
|
|
41
|
+
loopCount: number;
|
|
42
|
+
autoReviewedThisRound: boolean;
|
|
43
|
+
autoRunning: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function freshState(): TriggerState {
|
|
47
|
+
return { stuckErrors: 0, lastFingerprint: "", loopCount: 0, autoReviewedThisRound: false, autoRunning: false };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function registerTriggers(pi: ExtensionAPI): void {
|
|
51
|
+
// One state slot per registered extension instance. pi loads the extension
|
|
52
|
+
// once per session, so this is effectively per-session. (If pi ever runs
|
|
53
|
+
// extensions across multiple concurrent sessions in one process, this would
|
|
54
|
+
// need to key by session id — not the case today.)
|
|
55
|
+
const state = freshState();
|
|
56
|
+
|
|
57
|
+
// Reset point: clear all counters at the start of each user prompt so a
|
|
58
|
+
// previous turn's stuck-state can't bleed into the next one.
|
|
59
|
+
pi.on("before_agent_start", () => {
|
|
60
|
+
state.stuckErrors = 0;
|
|
61
|
+
state.loopCount = 0;
|
|
62
|
+
state.lastFingerprint = "";
|
|
63
|
+
// NOTE: autoReviewedThisRound is reset here too, but autoRunning must NOT
|
|
64
|
+
// be — if a triggered consult is still in flight when the next prompt
|
|
65
|
+
// starts (rare but possible), clearing autoRunning would allow re-entry.
|
|
66
|
+
if (!state.autoRunning) state.autoReviewedThisRound = false;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// whenStuck: fires on the tool_result event (after we know isError + input).
|
|
70
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
71
|
+
// Self-trigger guard (a): our own consult tool's results don't count.
|
|
72
|
+
if (event.toolName === CONSULT_TOOL_NAME) return;
|
|
73
|
+
|
|
74
|
+
// Re-entrancy + config gates. Bail fast on every condition that disables
|
|
75
|
+
// the trigger — never let an auto-trigger break the turn.
|
|
76
|
+
if (state.autoRunning) return;
|
|
77
|
+
const config = loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
|
|
78
|
+
if (!config.enabled) return;
|
|
79
|
+
if (!ctx.isProjectTrusted()) return; // trust gate
|
|
80
|
+
const whenStuck = config.triggers?.whenStuck ?? 3;
|
|
81
|
+
if (whenStuck <= 0) return;
|
|
82
|
+
|
|
83
|
+
// Loop-detect fingerprint: toolName + full input, UN-TRUNCATED.
|
|
84
|
+
// (pi-extensions CHANGELOG: an earlier 120-char cap broke detection by
|
|
85
|
+
// collapsing distinct calls with shared prefixes into false matches.)
|
|
86
|
+
const fingerprint = `${event.toolName}:${JSON.stringify(event.input ?? "")}`;
|
|
87
|
+
if (fingerprint === state.lastFingerprint) {
|
|
88
|
+
state.loopCount++;
|
|
89
|
+
} else {
|
|
90
|
+
state.lastFingerprint = fingerprint;
|
|
91
|
+
state.loopCount = 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Error tracking.
|
|
95
|
+
if (event.isError) state.stuckErrors++;
|
|
96
|
+
else state.stuckErrors = 0;
|
|
97
|
+
|
|
98
|
+
// Error trigger: N consecutive errors.
|
|
99
|
+
if (state.stuckErrors >= whenStuck) {
|
|
100
|
+
state.stuckErrors = 0;
|
|
101
|
+
state.loopCount = 0;
|
|
102
|
+
state.lastFingerprint = "";
|
|
103
|
+
await runTriggeredConsult(
|
|
104
|
+
pi, ctx, config, state,
|
|
105
|
+
(text) =>
|
|
106
|
+
`The agent has hit ${whenStuck} consecutive tool errors. An advisor model was consulted:\n\n${text}\n\nUse this to get unstuck.`,
|
|
107
|
+
"steer",
|
|
108
|
+
);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Loop trigger: same tool + identical arguments repeated N times.
|
|
113
|
+
if (state.loopCount >= whenStuck) {
|
|
114
|
+
state.loopCount = 0;
|
|
115
|
+
state.lastFingerprint = "";
|
|
116
|
+
await runTriggeredConsult(
|
|
117
|
+
pi, ctx, config, state,
|
|
118
|
+
(text) =>
|
|
119
|
+
`The agent appears to be stuck in a loop (repeated tool "${event.toolName}" with identical arguments). An advisor model was consulted:\n\n${text}\n\nUse this to get unstuck.`,
|
|
120
|
+
"steer",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// onDone: fires when the agent finishes the turn.
|
|
126
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
127
|
+
if (state.autoRunning) return;
|
|
128
|
+
const config = loadConfig({ cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
|
|
129
|
+
if (!config.enabled) return;
|
|
130
|
+
if (!ctx.isProjectTrusted()) return;
|
|
131
|
+
const onDone = config.triggers?.onDone ?? false;
|
|
132
|
+
if (!onDone) return;
|
|
133
|
+
if (state.autoReviewedThisRound) return; // at most one auto-review per prompt
|
|
134
|
+
|
|
135
|
+
state.autoReviewedThisRound = true;
|
|
136
|
+
await runTriggeredConsult(
|
|
137
|
+
pi, ctx, config, state,
|
|
138
|
+
(text) =>
|
|
139
|
+
`Before finishing, an advisor model assessed your work:\n\n${text}\n\n` +
|
|
140
|
+
`If it raises valid issues, address them; otherwise briefly confirm and stop.`,
|
|
141
|
+
"followUp",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run one triggered consult and inject the result via sendUserMessage.
|
|
148
|
+
*
|
|
149
|
+
* The consult itself (executeSolo/executeCouncil) is safe to call from a
|
|
150
|
+
* handler — it does its own completeSimple and returns. What's NOT safe is
|
|
151
|
+
* calling session-control methods (those deadlock); we avoid that by using
|
|
152
|
+
* pi.sendUserMessage({ deliverAs }) which is the documented injection path.
|
|
153
|
+
*
|
|
154
|
+
* Re-entrancy: autoRunning is set for the duration so the consult's own
|
|
155
|
+
* tool_result event can't re-trip the detectors. try/finally so a thrown
|
|
156
|
+
* consult error can't leave autoRunning stuck true.
|
|
157
|
+
*/
|
|
158
|
+
async function runTriggeredConsult(
|
|
159
|
+
pi: ExtensionAPI,
|
|
160
|
+
ctx: ExtensionContext,
|
|
161
|
+
config: BpxConsultConfig,
|
|
162
|
+
state: TriggerState,
|
|
163
|
+
buildMessage: (text: string) => string,
|
|
164
|
+
deliverAs: "steer" | "followUp",
|
|
165
|
+
): Promise<void> {
|
|
166
|
+
state.autoRunning = true;
|
|
167
|
+
try {
|
|
168
|
+
// Auto-triggers ALWAYS run solo, regardless of defaultMode (§T). Rationale:
|
|
169
|
+
// an auto-fire is not a deliberate consultation — it's a safety net firing
|
|
170
|
+
// mid-turn. A council would burn 3+ model calls + synthesis per trigger,
|
|
171
|
+
// which is a surprise-quota footgun on a loop or repeated errors. Council
|
|
172
|
+
// is reserved for explicit invocation (mode:council tool arg, /consult).
|
|
173
|
+
const result = await executeSolo({ ctx, config, signal: ctx.signal, onUpdate: undefined });
|
|
174
|
+
|
|
175
|
+
// Extract text from the tool result content blocks.
|
|
176
|
+
const text = result.content
|
|
177
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
178
|
+
.map((c) => c.text)
|
|
179
|
+
.join("\n")
|
|
180
|
+
.trim();
|
|
181
|
+
|
|
182
|
+
if (text) {
|
|
183
|
+
await pi.sendUserMessage(buildMessage(text), { deliverAs });
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// never let an auto-trigger break the turn
|
|
187
|
+
} finally {
|
|
188
|
+
state.autoRunning = false;
|
|
189
|
+
}
|
|
190
|
+
}
|