@ilya-lesikov/pi-pi 0.4.0 → 0.6.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/3p/pi-ask-user/index.ts +65 -49
- package/3p/pi-subagents/src/agent-manager.ts +8 -0
- package/3p/pi-subagents/src/agent-runner.ts +112 -19
- package/3p/pi-subagents/src/index.ts +3 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +32 -19
- package/extensions/orchestrator/agents/code-reviewer.ts +31 -17
- package/extensions/orchestrator/agents/constraints.ts +55 -0
- package/extensions/orchestrator/agents/explore.ts +13 -13
- package/extensions/orchestrator/agents/librarian.ts +12 -9
- package/extensions/orchestrator/agents/plan-reviewer.ts +31 -19
- package/extensions/orchestrator/agents/planner.ts +28 -23
- package/extensions/orchestrator/agents/registry.ts +2 -1
- package/extensions/orchestrator/agents/repo-context.ts +11 -0
- package/extensions/orchestrator/agents/task.ts +14 -11
- package/extensions/orchestrator/agents/tool-routing.ts +17 -32
- package/extensions/orchestrator/ast-search.ts +2 -1
- package/extensions/orchestrator/cbm.test.ts +35 -0
- package/extensions/orchestrator/cbm.ts +43 -13
- package/extensions/orchestrator/command-handlers.test.ts +390 -19
- package/extensions/orchestrator/command-handlers.ts +73 -31
- package/extensions/orchestrator/commands.test.ts +255 -2
- package/extensions/orchestrator/commands.ts +108 -10
- package/extensions/orchestrator/config.test.ts +289 -68
- package/extensions/orchestrator/config.ts +630 -121
- package/extensions/orchestrator/context.test.ts +177 -10
- package/extensions/orchestrator/context.ts +115 -14
- package/extensions/orchestrator/custom-footer.ts +2 -1
- package/extensions/orchestrator/doctor.test.ts +559 -0
- package/extensions/orchestrator/doctor.ts +664 -0
- package/extensions/orchestrator/event-handlers.test.ts +84 -22
- package/extensions/orchestrator/event-handlers.ts +1191 -360
- package/extensions/orchestrator/exa.test.ts +46 -0
- package/extensions/orchestrator/exa.ts +16 -10
- package/extensions/orchestrator/flant-infra.test.ts +224 -0
- package/extensions/orchestrator/flant-infra.ts +110 -41
- package/extensions/orchestrator/index.ts +13 -2
- package/extensions/orchestrator/integration.test.ts +2866 -118
- package/extensions/orchestrator/log.test.ts +219 -0
- package/extensions/orchestrator/log.ts +153 -0
- package/extensions/orchestrator/model-registry.test.ts +238 -0
- package/extensions/orchestrator/model-registry.ts +282 -0
- package/extensions/orchestrator/model-version.test.ts +27 -0
- package/extensions/orchestrator/model-version.ts +19 -0
- package/extensions/orchestrator/orchestrator.test.ts +206 -56
- package/extensions/orchestrator/orchestrator.ts +295 -148
- package/extensions/orchestrator/phases/brainstorm.test.ts +10 -7
- package/extensions/orchestrator/phases/brainstorm.ts +41 -35
- package/extensions/orchestrator/phases/implementation.ts +7 -11
- package/extensions/orchestrator/phases/machine.test.ts +27 -8
- package/extensions/orchestrator/phases/machine.ts +13 -0
- package/extensions/orchestrator/phases/planning.ts +57 -29
- package/extensions/orchestrator/phases/review-task.ts +3 -3
- package/extensions/orchestrator/phases/review.ts +38 -39
- package/extensions/orchestrator/phases/spawn-blocking.test.ts +69 -0
- package/extensions/orchestrator/phases/verdict.test.ts +139 -0
- package/extensions/orchestrator/phases/verdict.ts +82 -0
- package/extensions/orchestrator/plannotator.test.ts +85 -0
- package/extensions/orchestrator/plannotator.ts +24 -6
- package/extensions/orchestrator/pp-menu.test.ts +134 -0
- package/extensions/orchestrator/pp-menu.ts +2631 -392
- package/extensions/orchestrator/repo-utils.test.ts +151 -0
- package/extensions/orchestrator/repo-utils.ts +67 -0
- package/extensions/orchestrator/spawn-cleanup.test.ts +57 -0
- package/extensions/orchestrator/spawn-cleanup.ts +35 -0
- package/extensions/orchestrator/state.test.ts +76 -6
- package/extensions/orchestrator/state.ts +89 -26
- package/extensions/orchestrator/subagent-session-marker.test.ts +36 -0
- package/extensions/orchestrator/test-helpers.ts +217 -0
- package/extensions/orchestrator/tracer.test.ts +132 -0
- package/extensions/orchestrator/tracer.ts +127 -0
- package/extensions/orchestrator/transition-controller.test.ts +207 -0
- package/extensions/orchestrator/transition-controller.ts +259 -0
- package/extensions/orchestrator/usage-tracker.test.ts +435 -0
- package/extensions/orchestrator/usage-tracker.ts +23 -2
- package/extensions/orchestrator/validate-artifacts.test.ts +83 -1
- package/package.json +2 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { TransitionController, type TransitionHost, type MainSession } from "./transition-controller.js";
|
|
3
|
+
|
|
4
|
+
// Fake host + session: records outbound primitives and lets tests control idle +
|
|
5
|
+
// compaction. `controller` is pre-wired with both.
|
|
6
|
+
function makeHost(opts: { idle?: boolean; step?: string | null; canCompact?: boolean } = {}) {
|
|
7
|
+
let compactCb: { onComplete?: () => void; onError?: (e: Error) => void } | null = null;
|
|
8
|
+
const calls = {
|
|
9
|
+
userMessages: [] as Array<{ text: string; deliverAs: string }>,
|
|
10
|
+
customMessages: [] as Array<{ customType: string; deliverAs: string }>,
|
|
11
|
+
compactStarted: 0,
|
|
12
|
+
};
|
|
13
|
+
const session: MainSession = {
|
|
14
|
+
sendUserMessage: (text, options) => calls.userMessages.push({ text, deliverAs: options?.deliverAs ?? "" }),
|
|
15
|
+
sendMessage: (message, options) => calls.customMessages.push({ customType: message.customType, deliverAs: options?.deliverAs ?? "" }),
|
|
16
|
+
};
|
|
17
|
+
const host: TransitionHost = {
|
|
18
|
+
compact: (options) => {
|
|
19
|
+
if (opts.canCompact === false) return false;
|
|
20
|
+
calls.compactStarted++;
|
|
21
|
+
compactCb = options;
|
|
22
|
+
return true;
|
|
23
|
+
},
|
|
24
|
+
isIdle: () => opts.idle ?? false,
|
|
25
|
+
currentStep: () => opts.step ?? "llm_work",
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
host,
|
|
29
|
+
session,
|
|
30
|
+
calls,
|
|
31
|
+
controller: new TransitionController(host, session),
|
|
32
|
+
// Simulate the SDK firing the compaction completion event.
|
|
33
|
+
completeCompaction: () => compactCb?.onComplete?.(),
|
|
34
|
+
failCompaction: (msg: string) => compactCb?.onError?.(new Error(msg)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("TransitionController.send", () => {
|
|
39
|
+
it("delivers instruction as followUp (always starts a turn)", () => {
|
|
40
|
+
const { host, session, calls } = makeHost();
|
|
41
|
+
const c = new TransitionController(host, session);
|
|
42
|
+
c.send("go", "instruction");
|
|
43
|
+
expect(calls.userMessages).toEqual([{ text: "go", deliverAs: "followUp" }]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects context role (plain user messages always start a turn)", () => {
|
|
47
|
+
const { host, session, calls } = makeHost();
|
|
48
|
+
const c = new TransitionController(host, session);
|
|
49
|
+
expect(() => c.send("ctx", "context")).toThrow(/sendCustom/);
|
|
50
|
+
expect(calls.userMessages).toHaveLength(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("sendCustom maps roles to delivery modes", () => {
|
|
54
|
+
const { host, session, calls } = makeHost();
|
|
55
|
+
const c = new TransitionController(host, session);
|
|
56
|
+
c.sendCustom({ customType: "pp-context", content: "x", display: false }, "context");
|
|
57
|
+
c.sendCustom({ customType: "pp-artifact", content: "y", display: false }, "instruction");
|
|
58
|
+
expect(calls.customMessages).toEqual([
|
|
59
|
+
{ customType: "pp-context", deliverAs: "steer" },
|
|
60
|
+
{ customType: "pp-artifact", deliverAs: "followUp" },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("TransitionController.isRunning / shouldBlockAgentStart", () => {
|
|
66
|
+
it("running with llm_work step is running and does not block", () => {
|
|
67
|
+
const { host, session } = makeHost({ step: "llm_work" });
|
|
68
|
+
const c = new TransitionController(host, session);
|
|
69
|
+
expect(c.isRunning()).toBe(true);
|
|
70
|
+
expect(c.shouldBlockAgentStart()).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("await_planners and await_reviewers steps are not running (block)", () => {
|
|
74
|
+
for (const step of ["await_planners", "await_reviewers"]) {
|
|
75
|
+
const { host, session } = makeHost({ step });
|
|
76
|
+
const c = new TransitionController(host, session);
|
|
77
|
+
expect(c.isRunning()).toBe(false);
|
|
78
|
+
expect(c.shouldBlockAgentStart()).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("gateAgentStart aborts and reports when not running, no-op when running", () => {
|
|
83
|
+
const running = makeHost({ step: "llm_work" });
|
|
84
|
+
const rc = new TransitionController(running.host, running.session);
|
|
85
|
+
const abort1 = vi.fn();
|
|
86
|
+
expect(rc.gateAgentStart(abort1)).toBe(false);
|
|
87
|
+
expect(abort1).not.toHaveBeenCalled();
|
|
88
|
+
|
|
89
|
+
const waiting = makeHost({ step: "await_planners" });
|
|
90
|
+
const wc = new TransitionController(waiting.host, waiting.session);
|
|
91
|
+
const abort2 = vi.fn();
|
|
92
|
+
expect(wc.gateAgentStart(abort2)).toBe(true);
|
|
93
|
+
expect(abort2).toHaveBeenCalledOnce();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("abortMainAgent issues the abort (controller-owned cleanup abort)", () => {
|
|
97
|
+
const { controller } = makeHost();
|
|
98
|
+
const abort = vi.fn();
|
|
99
|
+
controller.abortMainAgent(abort);
|
|
100
|
+
expect(abort).toHaveBeenCalledOnce();
|
|
101
|
+
expect(() => controller.abortMainAgent(undefined)).not.toThrow();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("TransitionController phase transition flow", () => {
|
|
106
|
+
it("waits for agent_end when not idle, then compacts and resumes", async () => {
|
|
107
|
+
const { host, session, calls, completeCompaction } = makeHost({ idle: false });
|
|
108
|
+
const c = new TransitionController(host, session);
|
|
109
|
+
const onResume = vi.fn();
|
|
110
|
+
const p = c.requestTransition({ kind: "phase", summary: "s", onResume, instruction: "Begin working." });
|
|
111
|
+
expect(c.getState()).toBe("pending");
|
|
112
|
+
expect(calls.compactStarted).toBe(0); // not idle -> waits
|
|
113
|
+
|
|
114
|
+
c.onAgentEnd();
|
|
115
|
+
expect(c.getState()).toBe("compacting");
|
|
116
|
+
expect(calls.compactStarted).toBe(1);
|
|
117
|
+
|
|
118
|
+
completeCompaction();
|
|
119
|
+
await p;
|
|
120
|
+
expect(onResume).toHaveBeenCalledOnce();
|
|
121
|
+
expect(c.getState()).toBe("running");
|
|
122
|
+
expect(calls.userMessages).toEqual([{ text: "Begin working.", deliverAs: "followUp" }]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("already-idle path compacts immediately without agent_end", async () => {
|
|
126
|
+
const { host, session, calls, completeCompaction } = makeHost({ idle: true });
|
|
127
|
+
const c = new TransitionController(host, session);
|
|
128
|
+
const p = c.requestTransition({ kind: "done", summary: "done" });
|
|
129
|
+
expect(c.getState()).toBe("compacting");
|
|
130
|
+
expect(calls.compactStarted).toBe(1);
|
|
131
|
+
completeCompaction();
|
|
132
|
+
await p;
|
|
133
|
+
expect(c.getState()).toBe("running");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("treats compact no-op errors as a clean resume", async () => {
|
|
137
|
+
for (const msg of ["Nothing to compact (session too small)", "Already compacted"]) {
|
|
138
|
+
const { host, session, failCompaction } = makeHost({ idle: true });
|
|
139
|
+
const c = new TransitionController(host, session);
|
|
140
|
+
const onResume = vi.fn();
|
|
141
|
+
const p = c.requestTransition({ kind: "phase", onResume, instruction: "go" });
|
|
142
|
+
failCompaction(msg);
|
|
143
|
+
await p;
|
|
144
|
+
expect(onResume).toHaveBeenCalledOnce();
|
|
145
|
+
expect(c.getState()).toBe("running");
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("resumes via session_compact event", async () => {
|
|
150
|
+
const { host, session } = makeHost({ idle: false });
|
|
151
|
+
const c = new TransitionController(host, session);
|
|
152
|
+
const onResume = vi.fn();
|
|
153
|
+
const p = c.requestTransition({ kind: "phase", onResume, instruction: "go" });
|
|
154
|
+
c.onAgentEnd();
|
|
155
|
+
c.onSessionCompact();
|
|
156
|
+
await p;
|
|
157
|
+
expect(onResume).toHaveBeenCalledOnce();
|
|
158
|
+
expect(c.getState()).toBe("running");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("ignores agent_end while running (no self-trigger loop)", () => {
|
|
162
|
+
const { host, session, calls } = makeHost({ idle: false });
|
|
163
|
+
const c = new TransitionController(host, session);
|
|
164
|
+
c.onAgentEnd();
|
|
165
|
+
c.onAgentEnd();
|
|
166
|
+
expect(calls.compactStarted).toBe(0);
|
|
167
|
+
expect(c.getState()).toBe("running");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("ignores session_compact while running (no spurious resume)", () => {
|
|
171
|
+
const { host, session, calls } = makeHost();
|
|
172
|
+
const c = new TransitionController(host, session);
|
|
173
|
+
c.onSessionCompact();
|
|
174
|
+
expect(calls.userMessages).toHaveLength(0);
|
|
175
|
+
expect(c.getState()).toBe("running");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("resolves the awaitable even when no live ctx can compact", async () => {
|
|
179
|
+
const { host, session, calls } = makeHost({ idle: true, canCompact: false });
|
|
180
|
+
const c = new TransitionController(host, session);
|
|
181
|
+
const onResume = vi.fn();
|
|
182
|
+
await c.requestTransition({ kind: "done", onResume });
|
|
183
|
+
expect(onResume).toHaveBeenCalledOnce();
|
|
184
|
+
expect(calls.compactStarted).toBe(0);
|
|
185
|
+
expect(c.getState()).toBe("running");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("does not send an instruction when none is provided (await_planners notify-only)", async () => {
|
|
189
|
+
const { host, session, calls, completeCompaction } = makeHost({ idle: true });
|
|
190
|
+
const c = new TransitionController(host, session);
|
|
191
|
+
const p = c.requestTransition({ kind: "phase", onResume: () => {} });
|
|
192
|
+
completeCompaction();
|
|
193
|
+
await p;
|
|
194
|
+
expect(calls.userMessages).toHaveLength(0);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("compaction completion is idempotent across onComplete and session_compact", async () => {
|
|
198
|
+
const { host, session, completeCompaction } = makeHost({ idle: true });
|
|
199
|
+
const c = new TransitionController(host, session);
|
|
200
|
+
const onResume = vi.fn();
|
|
201
|
+
const p = c.requestTransition({ kind: "phase", onResume, instruction: "go" });
|
|
202
|
+
completeCompaction();
|
|
203
|
+
c.onSessionCompact(); // second terminus — must be a no-op
|
|
204
|
+
await p;
|
|
205
|
+
expect(onResume).toHaveBeenCalledOnce();
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { getLogger } from "./log.js";
|
|
2
|
+
|
|
3
|
+
// Role of an outbound message. Drives the SDK delivery mode:
|
|
4
|
+
// - "context": injected background context/artifacts. Delivered as "steer" so
|
|
5
|
+
// it never starts a turn (the agent is or will be working).
|
|
6
|
+
// - "instruction": a handoff that should make the agent act. Delivered as
|
|
7
|
+
// "followUp" so it queues and triggers a turn once the current one settles
|
|
8
|
+
// (or runs immediately when idle). Never throws "Agent is already processing".
|
|
9
|
+
export type SendRole = "context" | "instruction";
|
|
10
|
+
|
|
11
|
+
// Dependency passed to standalone phase-module spawn functions so they can emit
|
|
12
|
+
// status/context messages WITHOUT importing the orchestrator (avoids circular
|
|
13
|
+
// imports) while still routing every send through the controller. Bound to
|
|
14
|
+
// TransitionController.sendCustom by the caller.
|
|
15
|
+
export type PhaseSend = (
|
|
16
|
+
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
17
|
+
role: SendRole,
|
|
18
|
+
) => void;
|
|
19
|
+
|
|
20
|
+
// The kind of transition the controller is coordinating. Phase transitions resume
|
|
21
|
+
// the main loop with a "Begin working" instruction; done/stop transitions finish
|
|
22
|
+
// the task (cleanup already performed by the caller) and resolve their awaitable.
|
|
23
|
+
export type TransitionKind = "phase" | "done";
|
|
24
|
+
|
|
25
|
+
export interface TransitionRequest {
|
|
26
|
+
kind: TransitionKind;
|
|
27
|
+
// Compaction summary used by the session_before_compact handler.
|
|
28
|
+
summary?: string;
|
|
29
|
+
// Runs after compaction completes (or is skipped), before the resume message.
|
|
30
|
+
// Used for phase transitions to switch model + inject context/artifacts and to
|
|
31
|
+
// spawn planners at the right moment. Throwing here is logged, not fatal.
|
|
32
|
+
onResume?: () => void | Promise<void>;
|
|
33
|
+
// For phase transitions: the instruction sent after onResume. When omitted
|
|
34
|
+
// (e.g. plan/await_planners which only notifies, or done transitions), no
|
|
35
|
+
// instruction is sent.
|
|
36
|
+
instruction?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ControllerState = "running" | "pending" | "compacting" | "resuming";
|
|
40
|
+
|
|
41
|
+
// The raw main-session messaging surface (satisfied by ExtensionAPI / pi). The
|
|
42
|
+
// controller calls these directly so it is literally the only code that calls
|
|
43
|
+
// sendUserMessage/sendMessage — a whole-tree grep finds zero raw sends elsewhere.
|
|
44
|
+
export interface MainSession {
|
|
45
|
+
sendUserMessage(content: string, options?: { deliverAs?: "steer" | "followUp" }): void;
|
|
46
|
+
sendMessage(
|
|
47
|
+
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
48
|
+
options?: { deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
49
|
+
): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Live-session surface the controller needs from the Orchestrator (the bits that
|
|
53
|
+
// depend on the current ExtensionContext / persisted state). Kept as an interface
|
|
54
|
+
// so the controller is unit-testable with a fake host.
|
|
55
|
+
export interface TransitionHost {
|
|
56
|
+
// Compaction + idle probe come from the live ExtensionContext (lastCtx).
|
|
57
|
+
compact(options: { customInstructions?: string; onComplete?: () => void; onError?: (err: Error) => void }): boolean;
|
|
58
|
+
isIdle(): boolean;
|
|
59
|
+
// Persisted step, used to derive the await_* "not running" predicate.
|
|
60
|
+
currentStep(): string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const COMPACT_NOOP_MESSAGES = [
|
|
64
|
+
"Nothing to compact (session too small)",
|
|
65
|
+
"Already compacted",
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function isCompactNoop(err: unknown): boolean {
|
|
69
|
+
const msg = err instanceof Error ? err.message : String(err ?? "");
|
|
70
|
+
return COMPACT_NOOP_MESSAGES.some((m) => msg.includes(m));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Single event-driven owner of phase/task transitions and main-session
|
|
74
|
+
// compaction + outbound messaging. See transition-controller flow in the plan:
|
|
75
|
+
// requestTransition -> pending; agent_end (or already-idle) -> compact ->
|
|
76
|
+
// compacting; session_compact / compact no-op / error -> resuming -> onResume
|
|
77
|
+
// -> send(instruction) -> running.
|
|
78
|
+
export class TransitionController {
|
|
79
|
+
private state: ControllerState = "running";
|
|
80
|
+
private active: TransitionRequest | null = null;
|
|
81
|
+
// Resolvers for callers that await the transition (done/stop/new-task paths).
|
|
82
|
+
private waiters: Array<() => void> = [];
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly host: TransitionHost,
|
|
86
|
+
// The raw main-session messaging surface (pi). The controller calls it
|
|
87
|
+
// directly so no other code issues raw sendUserMessage/sendMessage.
|
|
88
|
+
private readonly session: MainSession,
|
|
89
|
+
) {}
|
|
90
|
+
|
|
91
|
+
// Pre-bound PhaseSend handed to standalone spawn functions so they route every
|
|
92
|
+
// outbound message through the controller without importing the orchestrator.
|
|
93
|
+
readonly phaseSend: PhaseSend = (message, role) => this.sendCustom(message, role);
|
|
94
|
+
|
|
95
|
+
getState(): ControllerState {
|
|
96
|
+
return this.state;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// True only when the controller is idle (running) AND not waiting on subagents.
|
|
100
|
+
// The await_* states live in persisted step, not the enum. Every consumer of
|
|
101
|
+
// "may the agent loop start / may we nudge / may the menu proceed" reads THIS.
|
|
102
|
+
isRunning(): boolean {
|
|
103
|
+
if (this.state !== "running") return false;
|
|
104
|
+
const step = this.host.currentStep();
|
|
105
|
+
return step !== "await_planners" && step !== "await_reviewers";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Inverse predicate used by in-tool / menu code to decide whether to abort.
|
|
109
|
+
shouldBlockAgentStart(): boolean {
|
|
110
|
+
return !this.isRunning();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The transition/await abort gate, owned by the controller. before_agent_start
|
|
114
|
+
// delegates here: the controller decides AND issues the abort (via the passed
|
|
115
|
+
// abort fn from the live ctx) so it is the sole owner of transition aborts.
|
|
116
|
+
// Returns true if the agent start was aborted.
|
|
117
|
+
gateAgentStart(abort: () => void): boolean {
|
|
118
|
+
if (this.isRunning()) return false;
|
|
119
|
+
getLogger().debug({ s: "controller", state: this.state }, "aborting agent start (not running)");
|
|
120
|
+
abort();
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Abort the in-flight main-agent turn ahead of an interactive pause/stop/finish
|
|
125
|
+
// cleanup. Routed through the controller so it is the sole owner of main-session
|
|
126
|
+
// aborts; the caller then performs cleanup and requests a done transition.
|
|
127
|
+
abortMainAgent(abort: (() => void) | undefined): void {
|
|
128
|
+
abort?.();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// True while a transition is mid-flight (pending/compacting/resuming) — i.e.
|
|
132
|
+
// the controller initiated the current compaction. Used by session_before_compact
|
|
133
|
+
// to decide between supplying the transition summary vs. re-injecting artifacts
|
|
134
|
+
// after a natural (user-triggered) compaction.
|
|
135
|
+
isTransitioning(): boolean {
|
|
136
|
+
return this.state !== "running";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Compaction summary for the in-flight transition (empty when not transitioning).
|
|
140
|
+
currentSummary(): string {
|
|
141
|
+
return this.active?.summary ?? "";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Outbound plain user-message. The ONLY path for main-session user messaging.
|
|
145
|
+
// Plain user messages (pi.sendUserMessage) ALWAYS start a turn — even with
|
|
146
|
+
// deliverAs:"steer" when idle (SDK prompt()) — so this only serves the
|
|
147
|
+
// "instruction" role (queue + start a turn via followUp). A non-turn-starting
|
|
148
|
+
// "context" payload must use sendCustom (a custom message that only appends
|
|
149
|
+
// when idle), so passing "context" here is a misuse and is rejected.
|
|
150
|
+
send(text: string, role: SendRole): void {
|
|
151
|
+
if (role === "context") {
|
|
152
|
+
throw new Error("send(context) would start a turn; use sendCustom for non-turn-starting context");
|
|
153
|
+
}
|
|
154
|
+
this.session.sendUserMessage(text, { deliverAs: "followUp" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Custom (non-LLM) messages (pp-context / pp-artifact). "context" => steer
|
|
158
|
+
// (append-only when idle, never starts a turn); "instruction" => followUp
|
|
159
|
+
// (queues + starts a turn while streaming; callers that need a guaranteed turn
|
|
160
|
+
// when idle pair this with a send(...) instruction).
|
|
161
|
+
sendCustom(message: { customType: string; content: string; display: boolean; details?: unknown }, role: SendRole): void {
|
|
162
|
+
this.session.sendMessage(message, { deliverAs: role === "context" ? "steer" : "followUp" });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Request a transition. Returns a promise that resolves once the transition
|
|
166
|
+
// has resumed (or finished, for done/stop). If the agent is already idle the
|
|
167
|
+
// controller compacts immediately; otherwise it waits for agent_end.
|
|
168
|
+
requestTransition(req: TransitionRequest): Promise<void> {
|
|
169
|
+
const log = getLogger();
|
|
170
|
+
if (this.state !== "running") {
|
|
171
|
+
// A transition is already in flight. Coalesce: ignore the new request but
|
|
172
|
+
// still attach the waiter so the caller is released when this settles.
|
|
173
|
+
if (req.kind === "phase") {
|
|
174
|
+
log.warn({ s: "controller", state: this.state }, "phase transition coalesced while not running — onResume/instruction dropped");
|
|
175
|
+
} else {
|
|
176
|
+
log.debug({ s: "controller", state: this.state, kind: req.kind }, "requestTransition while not running — coalescing");
|
|
177
|
+
}
|
|
178
|
+
return new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
179
|
+
}
|
|
180
|
+
this.active = req;
|
|
181
|
+
this.state = "pending";
|
|
182
|
+
log.debug({ s: "controller", kind: req.kind }, "transition pending");
|
|
183
|
+
const promise = new Promise<void>((resolve) => this.waiters.push(resolve));
|
|
184
|
+
// Already-idle path: no agent turn is in flight, so no agent_end will fire.
|
|
185
|
+
// Compact immediately rather than waiting forever.
|
|
186
|
+
if (this.host.isIdle()) {
|
|
187
|
+
this.beginCompaction();
|
|
188
|
+
}
|
|
189
|
+
return promise;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// agent_end fires when the main loop goes idle. If a transition is pending,
|
|
193
|
+
// this is the moment to compact. Repeated agent_end while running is a no-op
|
|
194
|
+
// (prevents the self-trigger loop from injected followUp messages).
|
|
195
|
+
onAgentEnd(): void {
|
|
196
|
+
if (this.state !== "pending") return;
|
|
197
|
+
this.beginCompaction();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// session_compact fires after compaction completes. Only advance the transition
|
|
201
|
+
// we initiated (state must be compacting) — an unrelated/manual compaction
|
|
202
|
+
// while running must not resume the wrong transition.
|
|
203
|
+
onSessionCompact(): void {
|
|
204
|
+
if (this.state !== "compacting") return;
|
|
205
|
+
void this.resume();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private beginCompaction(): void {
|
|
209
|
+
const req = this.active;
|
|
210
|
+
if (!req) {
|
|
211
|
+
this.state = "running";
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
this.state = "compacting";
|
|
215
|
+
getLogger().debug({ s: "controller", kind: req.kind }, "compacting");
|
|
216
|
+
const started = this.host.compact({
|
|
217
|
+
customInstructions: req.summary,
|
|
218
|
+
// onComplete is a fallback resume terminus; the authoritative one is the
|
|
219
|
+
// session_compact event. Whichever lands first wins (resume is idempotent).
|
|
220
|
+
onComplete: () => {
|
|
221
|
+
if (this.state === "compacting") void this.resume();
|
|
222
|
+
},
|
|
223
|
+
onError: (err: Error) => {
|
|
224
|
+
if (this.state !== "compacting") return;
|
|
225
|
+
if (isCompactNoop(err)) {
|
|
226
|
+
getLogger().debug({ s: "controller", err: err.message }, "compact no-op — resuming");
|
|
227
|
+
void this.resume();
|
|
228
|
+
} else {
|
|
229
|
+
getLogger().error({ s: "controller", err: err.message }, "compact error — resuming anyway");
|
|
230
|
+
void this.resume();
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
// host.compact returns false when no ctx is available (e.g. no live session);
|
|
235
|
+
// treat as a no-op and resume so awaiting callers don't hang.
|
|
236
|
+
if (!started) {
|
|
237
|
+
void this.resume();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private async resume(): Promise<void> {
|
|
242
|
+
const req = this.active;
|
|
243
|
+
this.state = "resuming";
|
|
244
|
+
getLogger().debug({ s: "controller", kind: req?.kind }, "resuming");
|
|
245
|
+
try {
|
|
246
|
+
await req?.onResume?.();
|
|
247
|
+
} catch (err: any) {
|
|
248
|
+
getLogger().error({ s: "controller", err: err?.message ?? String(err) }, "onResume failed");
|
|
249
|
+
}
|
|
250
|
+
if (req?.instruction) {
|
|
251
|
+
this.send(req.instruction, "instruction");
|
|
252
|
+
}
|
|
253
|
+
this.active = null;
|
|
254
|
+
this.state = "running";
|
|
255
|
+
const waiters = this.waiters;
|
|
256
|
+
this.waiters = [];
|
|
257
|
+
for (const w of waiters) w();
|
|
258
|
+
}
|
|
259
|
+
}
|