pi-delegation-policy 0.21.0 → 0.22.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 +52 -0
- package/README.md +47 -4
- package/package.json +1 -1
- package/src/advisor-executor.ts +114 -0
- package/src/agent-launch.ts +177 -38
- package/src/config-notice.ts +211 -0
- package/src/index.ts +141 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.22.0 - 2026-10-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- One ephemeral configuration-change notice on the next user turn after an effective configuration
|
|
8
|
+
change, whether a `/delegate` save or an edit of the global defaults that reaches the session. It
|
|
9
|
+
lists the values that changed since the configuration the model was last shown: intensity,
|
|
10
|
+
preference, ordinary roles, Visual Design, thinking policies, Advisor entries, mode and launch, the
|
|
11
|
+
ContextShunt mode, and the reader role, toggle and answer limit. The ContextShunt enforcement
|
|
12
|
+
tuning (`limits`, `exceptionPatterns`, `delegationHintPatterns`) is left out on purpose: it changes
|
|
13
|
+
how much the shunt preserves or compacts, not the assignments the model follows. The notice exists
|
|
14
|
+
only in the requests of that turn, never in the conversation, and stays identical across their
|
|
15
|
+
retries. With both axes off by configuration it reports one line,
|
|
16
|
+
`Delegation and Advisor disabled by this policy.`, and then injects nothing; an Advisor left on but
|
|
17
|
+
unusable is not reported as disabled.
|
|
18
|
+
- The reference is the configuration the model was actually shown. Changes made before a turn are
|
|
19
|
+
grouped, a reversion reports nothing, an edit that leaves effective values unchanged reports
|
|
20
|
+
nothing, and a change made while a turn is running waits for the next turn. The notice is consumed
|
|
21
|
+
when the run ends with its last recorded outcome completed, read from the final `agent_end`, so a
|
|
22
|
+
retried error that then succeeds consumes it while an error or abort reported before that point
|
|
23
|
+
leaves the change pending. Residual: an abort or a thrown error after the last successful response,
|
|
24
|
+
which the host reports through no later public event, can still consume it. Session starts,
|
|
25
|
+
reloads, forks and tree moves set the reference silently, and a prompt another extension sends with
|
|
26
|
+
`sendUserMessage` neither receives nor consumes the notice.
|
|
27
|
+
|
|
28
|
+
### Compatibility and verification
|
|
29
|
+
|
|
30
|
+
- No configuration, schema or dependency change. The notice uses public Pi `0.87.1` events,
|
|
31
|
+
is converted to user text by the host like any custom message and identifies itself as an extension
|
|
32
|
+
notice; it is not system policy and changes no model, permission or authority. The extension writes
|
|
33
|
+
no notice text to session history or its local log. This does not limit request retention by the
|
|
34
|
+
provider or other authorized host components. The existing local JSONL log keeps its fields.
|
|
35
|
+
- Covered against the registered hooks with a synthetic host: grouping and reversion, no-op saves,
|
|
36
|
+
retries, a mid-turn change, a completed turn followed by a failed continuation, error and abort, an
|
|
37
|
+
unknown run outcome, the accepted abort/throw window after a completed response, the both-off
|
|
38
|
+
notice, an extension-sent prompt, session and tree resets, global changes, the Advisor context mode,
|
|
39
|
+
and the real host conversion and payload repair. No real-provider run was performed. The synthetic
|
|
40
|
+
host encodes Pi `0.87.1`'s event order instead of exercising a host process, so a future host that
|
|
41
|
+
reordered `input` and `before_agent_start` would not be detected.
|
|
42
|
+
|
|
43
|
+
## 0.21.1 - 2026-10-19
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Accept valid Advisor replies in fork context. Preflight now uses the public executor wrapper to bind the same task that the host executes, while the emitted question and strict result identity checks remain unchanged. Version 0.21.0 discarded successful fork replies because the task digests differed.
|
|
48
|
+
- Explain Advisor launch failures instead of returning only `Advisor failed`. Messages distinguish missing launch prerequisites, preflight failures, timeouts, mismatched result identity, and unusable answers. If the host supplies no safely displayable cause, the message says so; raw errors, prompts, paths, and secrets are never copied into replies or the local log.
|
|
49
|
+
|
|
50
|
+
### Compatibility and verification
|
|
51
|
+
|
|
52
|
+
- No configuration or schema migration. Pi `0.87.1+` and the exact `pi-subagents` `0.76.1` executor requirement are unchanged. Reload after upgrading.
|
|
53
|
+
- The regression is covered against a synthetic host matching the installed executor, including rejection of a genuinely mismatched digest. A real-provider fork check remains pending after installation.
|
|
54
|
+
|
|
3
55
|
## 0.21.0 - 2026-10-19
|
|
4
56
|
|
|
5
57
|
### Added
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ general subagent runner. Only explicit tools can request reader work or Advisor
|
|
|
6
6
|
the authorized host executor; hooks never launch children.
|
|
7
7
|
|
|
8
8
|
> **Docs:** [yivas.github.io/pi-delegation-policy](https://yivas.github.io/pi-delegation-policy/).
|
|
9
|
-
> Version **0.
|
|
9
|
+
> Version **0.22.0** requires Pi `0.87.1` or later (`>=0.87.1`); it re-applies its marked policy block in the final provider payload.
|
|
10
10
|
> Version `0.14.1` supports Pi `0.84.3` or later (`>=0.84.3`).
|
|
11
11
|
>
|
|
12
12
|
> Independent Advisor mode and its companion file are available from version `0.17.0`.
|
|
@@ -15,7 +15,7 @@ the authorized host executor; hooks never launch children.
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
|
-
pi install npm:pi-delegation-policy@0.
|
|
18
|
+
pi install npm:pi-delegation-policy@0.22.0
|
|
19
19
|
# restart Pi, or run /reload
|
|
20
20
|
```
|
|
21
21
|
|
|
@@ -33,7 +33,7 @@ to inheritance later.
|
|
|
33
33
|
|
|
34
34
|
| Intensity | What the guidance asks |
|
|
35
35
|
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
36
|
-
| `off` | No execution-delegation guidance. An independently enabled Advisor still injects its own section; with both off
|
|
36
|
+
| `off` | No execution-delegation guidance. An independently enabled Advisor still injects its own section; with both off no policy block is injected. |
|
|
37
37
|
| `normal` | Delegate substantial, separable work only when the expected benefit clearly outweighs briefing, supervision, review, and integration. Borderline work stays with the main agent. |
|
|
38
38
|
| `aggressive` | Delegate suitable substantial, separable, independently checkable work by default when objective and acceptance criteria are clear. |
|
|
39
39
|
| `orchestrator` | Delegate all transferable execution before it begins whenever an enabled capable role and an authorized launcher exist, regardless of size: small lookups, reading, detailed planning, edits, tests, writing, detailed review, and integration mechanics. Direct work needs a briefly stated exception, and final acceptance stays with the main agent. |
|
|
@@ -105,7 +105,7 @@ Local timeouts and cancellation do not cap external generation, retries or cost.
|
|
|
105
105
|
| `off` | Inject no Advisor guidance and keep the saved model and thinking policy. |
|
|
106
106
|
| `with-delegation` | Consult only while delegation is active and an Advisor is configured. Default when the key is absent. |
|
|
107
107
|
|
|
108
|
-
`D:OFF A:ON` shows consultation-only guidance; `D:OFF A:OFF`
|
|
108
|
+
`D:OFF A:ON` shows consultation-only guidance; `D:OFF A:OFF` means neither axis injects a policy block. `/delegate reset`
|
|
109
109
|
turns both off for the branch; `/delegate off` leaves an independently enabled Advisor active.
|
|
110
110
|
|
|
111
111
|
Errors are reported per axis. Each entry validates independently: `A:ON` means all valid, `A:PART` means one of two valid
|
|
@@ -190,6 +190,49 @@ example `advisor-mode=with-delegation (global); A:OFF` and `thinking-advisor=uns
|
|
|
190
190
|
`D:OFF`, `D:NORM`, `D:AGG`, and `D:ORCH` describe delegation; `A:OFF`, `A:ON`, `A:PART`, and `A:ERR` describe
|
|
191
191
|
Advisor. None of them proves that a launch happened or that another system followed the guidance.
|
|
192
192
|
|
|
193
|
+
### Configuration change notice
|
|
194
|
+
|
|
195
|
+
Available from `0.22.0`. The declared Pi peer remains
|
|
196
|
+
`>=0.87.1`; the notice hooks and conversion are verified against `0.87.1` with a synthetic harness,
|
|
197
|
+
not a full host process or later host versions.
|
|
198
|
+
|
|
199
|
+
After an effective configuration change — a `/delegate` save, or an edit of the global defaults that
|
|
200
|
+
changes effective values — the next user turn carries one extension-owned notice in the request
|
|
201
|
+
context. It lists the values that changed since the configuration the model was last shown:
|
|
202
|
+
intensity, preference, ordinary roles, Visual Design, thinking policies, Advisor entries, mode and
|
|
203
|
+
launch, the ContextShunt mode, and the reader role, toggle and answer limit. The ContextShunt
|
|
204
|
+
enforcement tuning (`limits`, `exceptionPatterns`, `delegationHintPatterns`) is left out on purpose:
|
|
205
|
+
it changes how much the shunt preserves or compacts, not the assignments the model follows. The
|
|
206
|
+
notice is not system policy and never enters the conversation: it exists only in the requests of
|
|
207
|
+
that turn, identical across their retries, and it disappears with the turn.
|
|
208
|
+
|
|
209
|
+
The reference is the configuration the model was actually shown, so changes made before a turn are
|
|
210
|
+
grouped, a reversion before the turn reports nothing, and an edit that leaves effective values
|
|
211
|
+
unchanged reports nothing. Opening or cancelling the editor writes nothing and reports nothing. A
|
|
212
|
+
change made while a turn is already running reaches the policy immediately and is reported on the
|
|
213
|
+
next turn; a revision that has not been shown is never consumed by an earlier turn.
|
|
214
|
+
|
|
215
|
+
Consumption happens when the run ends and its last recorded outcome is a completed response: the
|
|
216
|
+
extension reads the last assistant message of the final `agent_end` and keeps the presented revision
|
|
217
|
+
only when that response completed. An error the host retries and that then succeeds counts as
|
|
218
|
+
completed, so the notice is consumed there. An error or an abort reported before that point leaves
|
|
219
|
+
the change pending, and the next user turn reports it again. Residual: an abort or a thrown error
|
|
220
|
+
after the last successful response, which the host reports through no later public event, can still
|
|
221
|
+
consume the notice; no public signal separates that window.
|
|
222
|
+
|
|
223
|
+
With delegation off and Advisor off by configuration, one notice says `Delegation and Advisor
|
|
224
|
+
disabled by this policy.` and nothing further is injected while both stay off. An Advisor left on but
|
|
225
|
+
unusable is not reported as disabled, because the configuration did not disable it. Every notice is
|
|
226
|
+
scoped to this extension and revokes no other instruction, permission, or authority. Session starts,
|
|
227
|
+
reloads, forks and tree navigation set the reference silently, so no notice appears at startup.
|
|
228
|
+
The extension writes no notice text to session history or its local log, so compaction summaries
|
|
229
|
+
and branches cannot inherit one from stored history. This does not limit the provider's or other
|
|
230
|
+
authorized host components' request retention. A prompt another extension sends with
|
|
231
|
+
`sendUserMessage` is not a user turn: it neither receives nor consumes the notice. The notice reports configuration, not availability: an unusable model keeps its value in the
|
|
232
|
+
list and the policy block that would assign it may simply be absent, so nothing in the notice
|
|
233
|
+
presents that model as usable. For new work, the notice points the model at the current valid
|
|
234
|
+
assignments and thinking rules in this policy rather than earlier settings in the conversation.
|
|
235
|
+
|
|
193
236
|
## ContextShunt
|
|
194
237
|
|
|
195
238
|
ContextShunt is a separate opt-in layer, off by default. `observe` records what enforcement would
|
package/package.json
CHANGED
package/src/advisor-executor.ts
CHANGED
|
@@ -3,11 +3,16 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import {
|
|
5
5
|
AgentLaunch,
|
|
6
|
+
LAUNCH_PREFLIGHT_TIMEOUT_MS,
|
|
7
|
+
LAUNCH_TERMINAL_TIMEOUT_MS,
|
|
6
8
|
type AgentLaunchDefinition,
|
|
7
9
|
type AgentLaunchDependencies,
|
|
10
|
+
type AgentLaunchFailure,
|
|
8
11
|
type AgentLaunchHost,
|
|
9
12
|
type AgentLaunchRequest,
|
|
10
13
|
type AgentLaunchResult,
|
|
14
|
+
type AgentLaunchTerminalField,
|
|
15
|
+
type AgentLaunchTerminalStatus,
|
|
11
16
|
} from "./agent-launch.ts";
|
|
12
17
|
import type { AdvisorContext } from "./prompt.ts";
|
|
13
18
|
import type { AdvisorSetting, ThinkingLevelName } from "./types.ts";
|
|
@@ -49,6 +54,115 @@ export function advisorThinking(setting: AdvisorSetting, model: Model<Api>): Thi
|
|
|
49
54
|
return supported.includes("high") ? "high" : supported[0]!;
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
type AdvisorFailureResult = Exclude<AgentLaunchResult<string>, { kind: "completed" }>;
|
|
58
|
+
|
|
59
|
+
function terminalFieldText(field: AgentLaunchTerminalField | undefined): string {
|
|
60
|
+
switch (field) {
|
|
61
|
+
case "agent":
|
|
62
|
+
return "agent profile";
|
|
63
|
+
case "model":
|
|
64
|
+
return "model";
|
|
65
|
+
case "thinking":
|
|
66
|
+
return "thinking level";
|
|
67
|
+
case "launchContractDigest":
|
|
68
|
+
return "launch contract digest";
|
|
69
|
+
default:
|
|
70
|
+
return "terminal metadata";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function hostStatusText(status: AgentLaunchTerminalStatus | undefined): string {
|
|
75
|
+
switch (status) {
|
|
76
|
+
case "invalid_request":
|
|
77
|
+
return "The host rejected the launch as an invalid request. Retry once; if it repeats, check the question and the packaged Advisor profile.";
|
|
78
|
+
case "unavailable_context":
|
|
79
|
+
return "The host could not supply the requested context. Call again with brief for a fresh consultation.";
|
|
80
|
+
case "duplicate_node":
|
|
81
|
+
return "The host treated this launch as a duplicate. Retry once.";
|
|
82
|
+
case "turn_budget_exhausted":
|
|
83
|
+
return "The host ended the Advisor run at its turn limit before answering. Retry once with a narrower question.";
|
|
84
|
+
case "tool_budget_exhausted":
|
|
85
|
+
return "The host ended the Advisor run at its tool limit before answering. Retry once with a narrower question.";
|
|
86
|
+
case "structured_output_failed":
|
|
87
|
+
return "The host ended the Advisor run while collecting its structured result. Retry once; if it repeats, inspect host diagnostics and check the compatibility of the installed pi-delegation-policy and pi-subagents 0.76.1 build.";
|
|
88
|
+
case "acceptance_failed":
|
|
89
|
+
return "The host ended the Advisor run at its acceptance check. Retry once; if it repeats, inspect host diagnostics and check the compatibility of the installed pi-delegation-policy and pi-subagents 0.76.1 build.";
|
|
90
|
+
case "failed":
|
|
91
|
+
return "The host ended the Advisor run with status failed and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
|
|
92
|
+
default:
|
|
93
|
+
return "The host ended the Advisor run and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function unavailableFailureText(reason: AgentLaunchFailure | undefined): string {
|
|
98
|
+
const preflightSeconds = LAUNCH_PREFLIGHT_TIMEOUT_MS / 1000;
|
|
99
|
+
switch (reason?.code) {
|
|
100
|
+
case "fork-task-normalizer-unavailable":
|
|
101
|
+
return "Fork launch is unavailable: the installed pi-subagents does not export wrapForkTask, so a fork task cannot be bound to the child. Retrying or supplying a brief will not switch this session to fresh; install the compatible pi-subagents 0.76.1 build and reload the session.";
|
|
102
|
+
case "modules-unavailable":
|
|
103
|
+
return "Advisor launch is unavailable: the pi-subagents 0.76.1 exports this extension requires were not found. Install the compatible pi-subagents 0.76.1 build, then reload the session.";
|
|
104
|
+
case "preflight-unavailable":
|
|
105
|
+
return "Advisor preflight returned no launch contract. Retry once; if it repeats, check the compatible pi-subagents 0.76.1 build and the packaged Advisor profile.";
|
|
106
|
+
case "preflight-timeout":
|
|
107
|
+
return `Advisor preflight did not finish within ${preflightSeconds} seconds. Retry once; if it repeats, check the installed pi-subagents version.`;
|
|
108
|
+
case "contract-rejected":
|
|
109
|
+
return "Advisor launch contract was rejected: preflight did not confirm the required profile, model, thinking, and tool set. Inspect host diagnostics and check the installed package versions and profile; no child was launched.";
|
|
110
|
+
case "request-start-timeout":
|
|
111
|
+
return `The host did not start the Advisor within ${preflightSeconds} seconds. Retry once; if it repeats, check the installed pi-subagents version.`;
|
|
112
|
+
case "request-emit-failed":
|
|
113
|
+
return "The host could not set up or send the Advisor launch event. Reload the session, then retry; if it repeats, check the installed extension against the compatible pi-subagents 0.76.1 build.";
|
|
114
|
+
case "host-session-missing":
|
|
115
|
+
return "Advisor unavailable: the host supplied no session identity.";
|
|
116
|
+
case "fork-not-allowed":
|
|
117
|
+
return "This launch cannot use fork context: the packaged profile does not allow it, and a brief does not change that. Check the installed package version and reload the session.";
|
|
118
|
+
case "launch-closed":
|
|
119
|
+
return "Advisor launch is closed for this session. Reload the session, then retry.";
|
|
120
|
+
case "host-terminal-status":
|
|
121
|
+
return hostStatusText(reason.status);
|
|
122
|
+
case "host-status-unrecognized":
|
|
123
|
+
return "The host ended the Advisor run with a status this extension does not recognize and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
|
|
124
|
+
default:
|
|
125
|
+
return "Advisor launch failed without a classified cause, and the host supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function failedFailureText(reason: AgentLaunchFailure | undefined): string {
|
|
130
|
+
switch (reason?.code) {
|
|
131
|
+
case "terminal-metadata-mismatch":
|
|
132
|
+
return `The host returned an Advisor reply whose ${terminalFieldText(reason.field)} did not match the preflight contract, so the reply was discarded. Retry once; if it repeats, report the mismatch.`;
|
|
133
|
+
case "terminal-result-invalid":
|
|
134
|
+
return "The host reported completion without a usable text answer, so the result was discarded. Retry once; if it repeats, inspect host diagnostics and check the compatibility of the extension with the installed pi-subagents 0.76.1 build.";
|
|
135
|
+
case "host-terminal-status":
|
|
136
|
+
return hostStatusText(reason.status);
|
|
137
|
+
case "host-status-unrecognized":
|
|
138
|
+
return "The host ended the Advisor run with a status this extension does not recognize and supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
|
|
139
|
+
default:
|
|
140
|
+
return "The Advisor run failed and the host supplied no safe detail. Retry once; if it repeats, check the installed pi-subagents version.";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bounded English cause for a failed Advisor launch. It renders only closed reason codes and
|
|
146
|
+
* admitted protocol metadata, so host error text, prompts, URLs, paths and secrets never reach the
|
|
147
|
+
* transcript or the local log.
|
|
148
|
+
*/
|
|
149
|
+
export function advisorFailureText(result: AdvisorFailureResult): string {
|
|
150
|
+
switch (result.kind) {
|
|
151
|
+
case "unavailable-context":
|
|
152
|
+
return "Fork unavailable. Call again with brief for a fresh consultation.";
|
|
153
|
+
case "busy":
|
|
154
|
+
return "Advisor busy: another consultation group is already in flight.";
|
|
155
|
+
case "cancelled":
|
|
156
|
+
return "Advisor cancelled before replying. Call again when ready.";
|
|
157
|
+
case "timed-out":
|
|
158
|
+
return `Advisor did not reply within ${LAUNCH_TERMINAL_TIMEOUT_MS / 1000} seconds. Inspect host diagnostics before retrying; cancellation does not guarantee that external generation or cost has stopped.`;
|
|
159
|
+
case "unavailable":
|
|
160
|
+
return unavailableFailureText(result.reason);
|
|
161
|
+
case "failed":
|
|
162
|
+
return failedFailureText(result.reason);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
52
166
|
/**
|
|
53
167
|
* One pending call per tool: an advisor group launches one child per configured advisor. The
|
|
54
168
|
* independent reader may hold its own pending call, so together they can own three children.
|
package/src/agent-launch.ts
CHANGED
|
@@ -60,6 +60,43 @@ export type AgentLaunchJsonSchema = Readonly<Record<string, unknown>>;
|
|
|
60
60
|
type AgentLaunchContract = Readonly<Record<string, unknown>>;
|
|
61
61
|
export type AgentLaunchPreflight = (input: unknown) => Promise<unknown>;
|
|
62
62
|
|
|
63
|
+
/** Terminal metadata the strict path binds; only these names may reach a consumer. */
|
|
64
|
+
export type AgentLaunchTerminalField = "agent" | "model" | "thinking" | "launchContractDigest";
|
|
65
|
+
/** Statuses the admitted delegation protocol can report for a terminal response. */
|
|
66
|
+
export type AgentLaunchTerminalStatus =
|
|
67
|
+
| "invalid_request"
|
|
68
|
+
| "unavailable_context"
|
|
69
|
+
| "duplicate_node"
|
|
70
|
+
| "turn_budget_exhausted"
|
|
71
|
+
| "tool_budget_exhausted"
|
|
72
|
+
| "structured_output_failed"
|
|
73
|
+
| "acceptance_failed"
|
|
74
|
+
| "failed";
|
|
75
|
+
export type AgentLaunchFailureCode =
|
|
76
|
+
| "launch-closed"
|
|
77
|
+
| "fork-not-allowed"
|
|
78
|
+
| "host-session-missing"
|
|
79
|
+
| "modules-unavailable"
|
|
80
|
+
| "fork-task-normalizer-unavailable"
|
|
81
|
+
| "preflight-unavailable"
|
|
82
|
+
| "preflight-timeout"
|
|
83
|
+
| "contract-rejected"
|
|
84
|
+
| "request-start-timeout"
|
|
85
|
+
| "request-emit-failed"
|
|
86
|
+
| "terminal-metadata-mismatch"
|
|
87
|
+
| "terminal-result-invalid"
|
|
88
|
+
| "host-terminal-status"
|
|
89
|
+
| "host-status-unrecognized";
|
|
90
|
+
/**
|
|
91
|
+
* Facts a consumer may render for a failed launch. Closed literals only: no host payload text,
|
|
92
|
+
* prompt, URL, path or provider message ever enters this type.
|
|
93
|
+
*/
|
|
94
|
+
export type AgentLaunchFailure = Readonly<{
|
|
95
|
+
code: AgentLaunchFailureCode;
|
|
96
|
+
field?: AgentLaunchTerminalField;
|
|
97
|
+
status?: AgentLaunchTerminalStatus;
|
|
98
|
+
}>;
|
|
99
|
+
|
|
63
100
|
export type AgentLaunchModules = Readonly<{
|
|
64
101
|
resolveSubagentLaunchContract: AgentLaunchPreflight;
|
|
65
102
|
/**
|
|
@@ -72,16 +109,22 @@ export type AgentLaunchModules = Readonly<{
|
|
|
72
109
|
startedEvent: string;
|
|
73
110
|
responseEvent: string;
|
|
74
111
|
cancelEvent: string;
|
|
112
|
+
/**
|
|
113
|
+
* The public `wrapForkTask` export from `pi-subagents/shared-types`. The executor wraps a fork
|
|
114
|
+
* task before hashing the launch binding, so the preflight must hash the same wrapped task or
|
|
115
|
+
* its digest can never match the terminal one. Absent on builds without that export.
|
|
116
|
+
*/
|
|
117
|
+
normalizeForkTask?: (task: string) => string;
|
|
75
118
|
}>;
|
|
76
119
|
|
|
77
120
|
export type AgentLaunchResult<Value> =
|
|
78
121
|
| { kind: "completed"; value: Value }
|
|
79
|
-
| { kind: "unavailable" }
|
|
80
|
-
| { kind: "unavailable-context" }
|
|
122
|
+
| { kind: "unavailable"; reason?: AgentLaunchFailure }
|
|
123
|
+
| { kind: "unavailable-context"; reason?: AgentLaunchFailure }
|
|
81
124
|
| { kind: "busy" }
|
|
82
125
|
| { kind: "cancelled" }
|
|
83
126
|
| { kind: "timed-out" }
|
|
84
|
-
| { kind: "failed" };
|
|
127
|
+
| { kind: "failed"; reason?: AgentLaunchFailure };
|
|
85
128
|
|
|
86
129
|
export type AgentLaunchResultExpectation<Request, Value> =
|
|
87
130
|
| Readonly<{
|
|
@@ -192,8 +235,9 @@ export function createDefaultAgentLaunchLoader(
|
|
|
192
235
|
loadModule("pi-subagents/preflight"),
|
|
193
236
|
loadModule("pi-subagents/delegation"),
|
|
194
237
|
loadModule("pi-subagents/child-tool-plan"),
|
|
238
|
+
loadForkTaskNormalizer(loadModule),
|
|
195
239
|
])
|
|
196
|
-
.then(([preflight, delegation, childToolPlan]) => {
|
|
240
|
+
.then(([preflight, delegation, childToolPlan, normalizeForkTask]) => {
|
|
197
241
|
if (!isRecord(preflight) || !isRecord(delegation) || !isRecord(childToolPlan)) {
|
|
198
242
|
return undefined;
|
|
199
243
|
}
|
|
@@ -239,11 +283,33 @@ export function createDefaultAgentLaunchLoader(
|
|
|
239
283
|
startedEvent,
|
|
240
284
|
responseEvent,
|
|
241
285
|
cancelEvent,
|
|
286
|
+
...(normalizeForkTask ? { normalizeForkTask } : {}),
|
|
242
287
|
};
|
|
243
288
|
})
|
|
244
289
|
.catch(() => undefined);
|
|
245
290
|
}
|
|
246
291
|
|
|
292
|
+
/**
|
|
293
|
+
* The public `wrapForkTask` export, loaded on its own: `pi-subagents` wraps a fork task before it
|
|
294
|
+
* hashes the launch binding, so the preflight needs the same wrapper to bind the same digest. A
|
|
295
|
+
* build without the export only loses fork launches, so its absence never disables the modules the
|
|
296
|
+
* fresh path uses.
|
|
297
|
+
*/
|
|
298
|
+
function loadForkTaskNormalizer(
|
|
299
|
+
loadModule: DynamicModuleLoader,
|
|
300
|
+
): Promise<((task: string) => string) | undefined> {
|
|
301
|
+
return loadModule("pi-subagents/shared-types")
|
|
302
|
+
.then((module) => {
|
|
303
|
+
if (!isRecord(module) || typeof module.wrapForkTask !== "function") return undefined;
|
|
304
|
+
const wrapForkTask = module.wrapForkTask;
|
|
305
|
+
return (task: string): string => {
|
|
306
|
+
const wrapped: unknown = wrapForkTask(task);
|
|
307
|
+
return typeof wrapped === "string" ? wrapped : "";
|
|
308
|
+
};
|
|
309
|
+
})
|
|
310
|
+
.catch(() => undefined);
|
|
311
|
+
}
|
|
312
|
+
|
|
247
313
|
/**
|
|
248
314
|
* The permission runtime entry the host added, when the public probe reports exactly one extra path
|
|
249
315
|
* after the prompt runtime and that path is the pinned scoped package. Any other shape, a probe that
|
|
@@ -313,13 +379,14 @@ function createPreflightInput<Request extends AgentLaunchRequest, Value>(
|
|
|
313
379
|
host: AgentLaunchHost,
|
|
314
380
|
requestId: string,
|
|
315
381
|
definition: AgentLaunchDefinition<Request, Value>,
|
|
382
|
+
task: string,
|
|
316
383
|
): Record<string, unknown> {
|
|
317
384
|
return {
|
|
318
385
|
agent: definition.agentName,
|
|
319
386
|
cwd: host.cwd,
|
|
320
387
|
parentSessionId: host.sessionId,
|
|
321
|
-
task
|
|
322
|
-
context:
|
|
388
|
+
task,
|
|
389
|
+
context: launchContext(request, definition),
|
|
323
390
|
model: `${request.model.provider}/${request.model.id}`,
|
|
324
391
|
thinking: request.model.thinking,
|
|
325
392
|
availableModels: request.availableModels,
|
|
@@ -370,7 +437,7 @@ function validateLaunchContract<Request extends AgentLaunchRequest, Value>(
|
|
|
370
437
|
const resolvedModel = modelName(request.model);
|
|
371
438
|
if (
|
|
372
439
|
!isLowerHexDigest(contract.launchContractDigest) ||
|
|
373
|
-
contract.context !== (
|
|
440
|
+
contract.context !== launchContext(request, definition) ||
|
|
374
441
|
// The contract used to carry `modelCandidates`, pinned here as exactly `[resolvedModel]`.
|
|
375
442
|
// `pi-subagents` 0.68.0 removed it along with `fallbackModels` and every same-launch model
|
|
376
443
|
// switch ("each launch uses one resolved model instead of switching models automatically"),
|
|
@@ -456,10 +523,35 @@ function validateLaunchContract<Request extends AgentLaunchRequest, Value>(
|
|
|
456
523
|
}
|
|
457
524
|
}
|
|
458
525
|
|
|
526
|
+
function launchContext<Request extends AgentLaunchRequest, Value>(
|
|
527
|
+
request: Request,
|
|
528
|
+
definition: AgentLaunchDefinition<Request, Value>,
|
|
529
|
+
): "fresh" | "fork" {
|
|
530
|
+
return definition.allowFork ? (request.context ?? "fresh") : "fresh";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The wrapped task the executor hashes for a fork, or `undefined` when no usable public wrapper is
|
|
535
|
+
* loaded. The request keeps the raw task: the host wraps it again on its own side.
|
|
536
|
+
*/
|
|
537
|
+
function preflightForkTask(
|
|
538
|
+
task: string,
|
|
539
|
+
normalizeForkTask: ((task: string) => string) | undefined,
|
|
540
|
+
): string | undefined {
|
|
541
|
+
if (!normalizeForkTask) return undefined;
|
|
542
|
+
try {
|
|
543
|
+
const normalized = normalizeForkTask(task);
|
|
544
|
+
return typeof normalized === "string" && normalized.length > 0 ? normalized : undefined;
|
|
545
|
+
} catch {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
459
550
|
function requestPayload<Request extends AgentLaunchRequest, Value>(
|
|
460
551
|
active: ActiveRun<Value>,
|
|
461
552
|
request: Request,
|
|
462
553
|
definition: AgentLaunchDefinition<Request, Value>,
|
|
554
|
+
task: string,
|
|
463
555
|
): Record<string, unknown> {
|
|
464
556
|
return {
|
|
465
557
|
requestId: active.requestId,
|
|
@@ -467,8 +559,8 @@ function requestPayload<Request extends AgentLaunchRequest, Value>(
|
|
|
467
559
|
nodeId: active.nodeId,
|
|
468
560
|
agent: definition.agentName,
|
|
469
561
|
cwd: active.host.cwd,
|
|
470
|
-
task
|
|
471
|
-
context:
|
|
562
|
+
task,
|
|
563
|
+
context: launchContext(request, definition),
|
|
472
564
|
model: `${request.model.provider}/${request.model.id}`,
|
|
473
565
|
thinking: request.model.thinking,
|
|
474
566
|
timeoutMs: LAUNCH_TERMINAL_TIMEOUT_MS,
|
|
@@ -523,11 +615,14 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
523
615
|
signal?: AbortSignal,
|
|
524
616
|
): Promise<AgentLaunchResult<Value>> {
|
|
525
617
|
if (this.closed || (request.context === "fork" && !this.definition.allowFork))
|
|
526
|
-
return Promise.resolve({
|
|
618
|
+
return Promise.resolve({
|
|
619
|
+
kind: "unavailable",
|
|
620
|
+
reason: { code: this.closed ? "launch-closed" : "fork-not-allowed" },
|
|
621
|
+
});
|
|
527
622
|
if (this.active) return Promise.resolve({ kind: "busy" });
|
|
528
623
|
// Without a host session the preflight could not bind required extensions; launch nothing.
|
|
529
624
|
if (typeof host.sessionId !== "string" || !host.sessionId.trim()) {
|
|
530
|
-
return Promise.resolve({ kind: "unavailable" });
|
|
625
|
+
return Promise.resolve({ kind: "unavailable", reason: { code: "host-session-missing" } });
|
|
531
626
|
}
|
|
532
627
|
return new Promise((resolveResult) => {
|
|
533
628
|
const active: ActiveRun<Value> = {
|
|
@@ -556,7 +651,7 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
556
651
|
};
|
|
557
652
|
this.active = active;
|
|
558
653
|
active.preflightTimer = this.setTimer(
|
|
559
|
-
() => active.settle({ kind: "unavailable" }),
|
|
654
|
+
() => active.settle({ kind: "unavailable", reason: { code: "preflight-timeout" } }),
|
|
560
655
|
LAUNCH_PREFLIGHT_TIMEOUT_MS,
|
|
561
656
|
);
|
|
562
657
|
if (signal) {
|
|
@@ -609,22 +704,48 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
609
704
|
}
|
|
610
705
|
if (!this.isLive(active)) return;
|
|
611
706
|
if (!modules) {
|
|
612
|
-
active.settle({ kind: "unavailable" });
|
|
707
|
+
active.settle({ kind: "unavailable", reason: { code: "modules-unavailable" } });
|
|
613
708
|
return;
|
|
614
709
|
}
|
|
615
710
|
active.modules = modules;
|
|
616
711
|
|
|
712
|
+
const task = this.definition.buildTask(request);
|
|
713
|
+
let preflightTask = task;
|
|
714
|
+
if (launchContext(request, this.definition) === "fork") {
|
|
715
|
+
const normalized = preflightForkTask(task, modules.normalizeForkTask);
|
|
716
|
+
if (normalized === undefined) {
|
|
717
|
+
active.settle({
|
|
718
|
+
kind: "unavailable",
|
|
719
|
+
reason: { code: "fork-task-normalizer-unavailable" },
|
|
720
|
+
});
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
preflightTask = normalized;
|
|
724
|
+
}
|
|
725
|
+
|
|
617
726
|
let resolved: unknown;
|
|
618
727
|
try {
|
|
619
728
|
resolved = await modules.resolveSubagentLaunchContract(
|
|
620
|
-
createPreflightInput(
|
|
729
|
+
createPreflightInput(
|
|
730
|
+
request,
|
|
731
|
+
active.host,
|
|
732
|
+
active.requestId,
|
|
733
|
+
this.definition,
|
|
734
|
+
preflightTask,
|
|
735
|
+
),
|
|
621
736
|
);
|
|
622
737
|
} catch {
|
|
623
738
|
resolved = undefined;
|
|
624
739
|
}
|
|
625
740
|
if (!this.isLive(active)) return;
|
|
741
|
+
// A thrown or empty preflight never described a contract; only a returned nonempty value can be
|
|
742
|
+
// rejected as a contract that failed the strict checks below.
|
|
743
|
+
if (resolved === undefined || resolved === null) {
|
|
744
|
+
active.settle({ kind: "unavailable", reason: { code: "preflight-unavailable" } });
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
626
747
|
if (!validateLaunchContract(resolved, request, active, this.definition, modules)) {
|
|
627
|
-
active.settle({ kind: "unavailable" });
|
|
748
|
+
active.settle({ kind: "unavailable", reason: { code: "contract-rejected" } });
|
|
628
749
|
return;
|
|
629
750
|
}
|
|
630
751
|
|
|
@@ -643,17 +764,17 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
643
764
|
active.requestSent = true;
|
|
644
765
|
this.clearPreflightTimer(active);
|
|
645
766
|
active.startTimer = this.setTimer(
|
|
646
|
-
() => this.cancelActive(active, "unavailable"),
|
|
767
|
+
() => this.cancelActive(active, "unavailable", { code: "request-start-timeout" }),
|
|
647
768
|
LAUNCH_PREFLIGHT_TIMEOUT_MS,
|
|
648
769
|
);
|
|
649
770
|
active.host.events.emit(
|
|
650
771
|
modules.requestEvent,
|
|
651
|
-
requestPayload(active, request, this.definition),
|
|
772
|
+
requestPayload(active, request, this.definition, task),
|
|
652
773
|
);
|
|
653
774
|
if (!this.isLive(active)) return;
|
|
654
775
|
} catch {
|
|
655
776
|
if (!active.settled) {
|
|
656
|
-
active.settle({ kind: "unavailable" });
|
|
777
|
+
active.settle({ kind: "unavailable", reason: { code: "request-emit-failed" } });
|
|
657
778
|
this.emitCancel(active);
|
|
658
779
|
}
|
|
659
780
|
}
|
|
@@ -687,21 +808,23 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
687
808
|
);
|
|
688
809
|
}
|
|
689
810
|
|
|
690
|
-
private
|
|
811
|
+
private terminalMetadataMismatch(
|
|
691
812
|
payload: Record<string, unknown>,
|
|
692
813
|
contract: AgentLaunchContract,
|
|
693
814
|
request: Request,
|
|
694
815
|
requireAll: boolean,
|
|
695
|
-
):
|
|
696
|
-
const expected =
|
|
697
|
-
agent
|
|
698
|
-
model
|
|
699
|
-
thinking
|
|
700
|
-
launchContractDigest
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
requireAll
|
|
704
|
-
|
|
816
|
+
): AgentLaunchTerminalField | undefined {
|
|
817
|
+
const expected: ReadonlyArray<readonly [AgentLaunchTerminalField, unknown]> = [
|
|
818
|
+
["agent", this.definition.agentName],
|
|
819
|
+
["model", contract.model],
|
|
820
|
+
["thinking", request.model.thinking],
|
|
821
|
+
["launchContractDigest", contract.launchContractDigest],
|
|
822
|
+
];
|
|
823
|
+
for (const [field, value] of expected) {
|
|
824
|
+
if (!requireAll && !(field in payload)) continue;
|
|
825
|
+
if (payload[field] !== value) return field;
|
|
826
|
+
}
|
|
827
|
+
return undefined;
|
|
705
828
|
}
|
|
706
829
|
|
|
707
830
|
private onResponse(
|
|
@@ -719,34 +842,46 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
719
842
|
|
|
720
843
|
if (!active.started) {
|
|
721
844
|
if (!earlyUnavailable) return;
|
|
845
|
+
const mismatch = this.terminalMetadataMismatch(payload, contract, request, false);
|
|
722
846
|
active.settle(
|
|
723
|
-
|
|
847
|
+
mismatch === undefined
|
|
724
848
|
? {
|
|
725
849
|
kind:
|
|
726
850
|
status === "unavailable_context" && this.definition.allowFork
|
|
727
851
|
? "unavailable-context"
|
|
728
852
|
: "unavailable",
|
|
853
|
+
reason: { code: "host-terminal-status", status },
|
|
729
854
|
}
|
|
730
|
-
: { kind: "failed" },
|
|
855
|
+
: { kind: "failed", reason: { code: "terminal-metadata-mismatch", field: mismatch } },
|
|
731
856
|
);
|
|
732
857
|
return;
|
|
733
858
|
}
|
|
734
859
|
|
|
735
860
|
if (status === "completed") {
|
|
736
|
-
|
|
737
|
-
|
|
861
|
+
const mismatch = this.terminalMetadataMismatch(payload, contract, request, true);
|
|
862
|
+
if (mismatch !== undefined) {
|
|
863
|
+
active.settle({
|
|
864
|
+
kind: "failed",
|
|
865
|
+
reason: { code: "terminal-metadata-mismatch", field: mismatch },
|
|
866
|
+
});
|
|
738
867
|
return;
|
|
739
868
|
}
|
|
740
869
|
const completed = completedValue(payload.result, this.definition.result);
|
|
741
870
|
active.settle(
|
|
742
|
-
completed.ok
|
|
871
|
+
completed.ok
|
|
872
|
+
? { kind: "completed", value: completed.value }
|
|
873
|
+
: { kind: "failed", reason: { code: "terminal-result-invalid" } },
|
|
743
874
|
);
|
|
744
875
|
return;
|
|
745
876
|
}
|
|
746
877
|
|
|
747
878
|
if (typeof status !== "string") return;
|
|
748
|
-
|
|
749
|
-
|
|
879
|
+
const mismatch = this.terminalMetadataMismatch(payload, contract, request, false);
|
|
880
|
+
if (mismatch !== undefined) {
|
|
881
|
+
active.settle({
|
|
882
|
+
kind: "failed",
|
|
883
|
+
reason: { code: "terminal-metadata-mismatch", field: mismatch },
|
|
884
|
+
});
|
|
750
885
|
return;
|
|
751
886
|
}
|
|
752
887
|
if (status === "cancelled" || status === "interrupted") {
|
|
@@ -767,6 +902,7 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
767
902
|
status === "unavailable_context" && this.definition.allowFork
|
|
768
903
|
? "unavailable-context"
|
|
769
904
|
: "unavailable",
|
|
905
|
+
reason: { code: "host-terminal-status", status },
|
|
770
906
|
});
|
|
771
907
|
return;
|
|
772
908
|
}
|
|
@@ -777,8 +913,10 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
777
913
|
status === "acceptance_failed" ||
|
|
778
914
|
status === "failed"
|
|
779
915
|
) {
|
|
780
|
-
active.settle({ kind: "failed" });
|
|
916
|
+
active.settle({ kind: "failed", reason: { code: "host-terminal-status", status } });
|
|
917
|
+
return;
|
|
781
918
|
}
|
|
919
|
+
active.settle({ kind: "failed", reason: { code: "host-status-unrecognized" } });
|
|
782
920
|
}
|
|
783
921
|
|
|
784
922
|
private emitCancel(active: ActiveRun<Value>): void {
|
|
@@ -798,9 +936,10 @@ export class AgentLaunch<Request extends AgentLaunchRequest, Value> {
|
|
|
798
936
|
private cancelActive(
|
|
799
937
|
active: ActiveRun<Value>,
|
|
800
938
|
result: "cancelled" | "unavailable" | "timed-out",
|
|
939
|
+
reason?: AgentLaunchFailure,
|
|
801
940
|
): void {
|
|
802
941
|
if (active.settled) return;
|
|
803
|
-
active.settle({ kind: result });
|
|
942
|
+
active.settle(reason ? { kind: result, reason } : { kind: result });
|
|
804
943
|
this.emitCancel(active);
|
|
805
944
|
}
|
|
806
945
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ContextWithSystemEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
ROLE_LABELS,
|
|
4
|
+
type AdvisorLaunch,
|
|
5
|
+
type AdvisorMode,
|
|
6
|
+
type ContextShuntMode,
|
|
7
|
+
type EffectiveDelegateState,
|
|
8
|
+
type Intensity,
|
|
9
|
+
type ModelRef,
|
|
10
|
+
type ModelRole,
|
|
11
|
+
type OrdinaryRoleSetting,
|
|
12
|
+
type Preference,
|
|
13
|
+
type ThinkingPolicy,
|
|
14
|
+
} from "./types.ts";
|
|
15
|
+
|
|
16
|
+
/** Custom message type of the notice. The host keeps it on the request copy only. */
|
|
17
|
+
export const CONFIG_NOTICE_TYPE = "pi-delegation-policy.config-notice";
|
|
18
|
+
/** The scope belongs to this policy, never to any other instruction or authority. */
|
|
19
|
+
export const BOTH_OFF_LINE = "Delegation and Advisor disabled by this policy.";
|
|
20
|
+
|
|
21
|
+
export type ConfigNoticeMessage = ContextWithSystemEvent["messages"][number];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An ordinary role has three effective states, and they must stay apart: not configured is an error
|
|
25
|
+
* while delegation is on, explicitly disabled is a valid partial policy, and a model reference
|
|
26
|
+
* delegates. The effective state of Visual Design only distinguishes present from absent, so it
|
|
27
|
+
* never carries `unset`.
|
|
28
|
+
*/
|
|
29
|
+
type RoleText = { kind: "unset" } | { kind: "disabled" } | { kind: "model"; reference: string };
|
|
30
|
+
/** `level:<level>`, `range:<min>..<max>`, or `null` when the role has no thinking policy. */
|
|
31
|
+
type ThinkingText = string | null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Deterministic view of the effective configuration that the notice compares. Derived status stays
|
|
35
|
+
* out: model availability, credentials, diagnostics, value provenance and the Advisor context mode
|
|
36
|
+
* cannot produce a notice, and neither can the rendered policy text.
|
|
37
|
+
*/
|
|
38
|
+
export type NoticeProjection = {
|
|
39
|
+
intensity: Intensity;
|
|
40
|
+
preference: Preference;
|
|
41
|
+
roles: Record<ModelRole, RoleText>;
|
|
42
|
+
uiDesign: RoleText;
|
|
43
|
+
thinking: Record<ModelRole | "uiDesign", ThinkingText>;
|
|
44
|
+
advisors: Array<{ model: string; thinking: ThinkingText }>;
|
|
45
|
+
advisorMode: AdvisorMode;
|
|
46
|
+
advisorLaunch: AdvisorLaunch;
|
|
47
|
+
context: {
|
|
48
|
+
mode: ContextShuntMode;
|
|
49
|
+
readerEnabled: boolean;
|
|
50
|
+
readerRole: ModelRole;
|
|
51
|
+
answerMaxBytes: number;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function thinkingText(policy: ThinkingPolicy | undefined): ThinkingText {
|
|
56
|
+
if (!policy) return null;
|
|
57
|
+
return "level" in policy ? `level:${policy.level}` : `range:${policy.min}..${policy.max}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ordinaryRoleText(setting: OrdinaryRoleSetting | undefined): RoleText {
|
|
61
|
+
if (setting === null) return { kind: "disabled" };
|
|
62
|
+
if (!setting) return { kind: "unset" };
|
|
63
|
+
return { kind: "model", reference: `${setting.provider}/${setting.model}` };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function optionalRoleText(setting: ModelRef | undefined): RoleText {
|
|
67
|
+
if (!setting) return { kind: "disabled" };
|
|
68
|
+
return { kind: "model", reference: `${setting.provider}/${setting.model}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function projectNoticeConfig(effective: EffectiveDelegateState): NoticeProjection {
|
|
72
|
+
return {
|
|
73
|
+
intensity: effective.intensity,
|
|
74
|
+
preference: effective.preference,
|
|
75
|
+
roles: {
|
|
76
|
+
small: ordinaryRoleText(effective.small),
|
|
77
|
+
medium: ordinaryRoleText(effective.medium),
|
|
78
|
+
large: ordinaryRoleText(effective.large),
|
|
79
|
+
},
|
|
80
|
+
uiDesign: optionalRoleText(effective.uiDesign),
|
|
81
|
+
thinking: {
|
|
82
|
+
small: thinkingText(effective.thinking.small),
|
|
83
|
+
medium: thinkingText(effective.thinking.medium),
|
|
84
|
+
large: thinkingText(effective.thinking.large),
|
|
85
|
+
uiDesign: thinkingText(effective.thinking.uiDesign),
|
|
86
|
+
},
|
|
87
|
+
advisors: effective.advisors.map((advisor) => ({
|
|
88
|
+
model: `${advisor.provider}/${advisor.model}`,
|
|
89
|
+
thinking: thinkingText(advisor.thinking),
|
|
90
|
+
})),
|
|
91
|
+
advisorMode: effective.advisorMode,
|
|
92
|
+
advisorLaunch: effective.advisorLaunch,
|
|
93
|
+
// The configured mode keeps an intensity edit from fabricating a second change.
|
|
94
|
+
context: {
|
|
95
|
+
mode: effective.contextShunt.configuredMode,
|
|
96
|
+
readerEnabled: effective.contextShunt.readerEnabled,
|
|
97
|
+
readerRole: effective.contextShunt.readerRole,
|
|
98
|
+
answerMaxBytes: effective.contextShunt.answerMaxBytes,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
type NoticeField = { label: string; value: string };
|
|
104
|
+
|
|
105
|
+
const TEXT_ROLES: readonly (ModelRole | "uiDesign")[] = ["small", "medium", "large", "uiDesign"];
|
|
106
|
+
|
|
107
|
+
function roleValue(text: RoleText): string {
|
|
108
|
+
if (text.kind === "model") return text.reference;
|
|
109
|
+
return text.kind === "disabled" ? "disabled" : "not configured";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function advisorModelValue(model: string | undefined): string {
|
|
113
|
+
return model ?? "none";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function thinkingValue(text: ThinkingText | undefined): string {
|
|
117
|
+
if (text === null || text === undefined) return "unset";
|
|
118
|
+
return text.startsWith("level:")
|
|
119
|
+
? `fixed ${text.slice("level:".length)}`
|
|
120
|
+
: `range ${text.slice("range:".length)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Fields in one canonical order. Advisor count is taken from both projections so a removed Advisor
|
|
125
|
+
* still reports its own two fields.
|
|
126
|
+
*/
|
|
127
|
+
function projectionFields(projection: NoticeProjection, advisorCount: number): NoticeField[] {
|
|
128
|
+
return [
|
|
129
|
+
{ label: "Delegation intensity", value: projection.intensity },
|
|
130
|
+
{ label: "Model preference", value: projection.preference },
|
|
131
|
+
...TEXT_ROLES.map((role) => ({
|
|
132
|
+
label: `${ROLE_LABELS[role]} model`,
|
|
133
|
+
value: roleValue(role === "uiDesign" ? projection.uiDesign : projection.roles[role]),
|
|
134
|
+
})),
|
|
135
|
+
...TEXT_ROLES.map((role) => ({
|
|
136
|
+
label: `${ROLE_LABELS[role]} thinking`,
|
|
137
|
+
value: thinkingValue(projection.thinking[role]),
|
|
138
|
+
})),
|
|
139
|
+
{ label: "Advisor mode", value: projection.advisorMode },
|
|
140
|
+
{ label: "Advisor launch", value: projection.advisorLaunch },
|
|
141
|
+
...Array.from({ length: advisorCount }, (_, index) => {
|
|
142
|
+
const advisor = projection.advisors[index];
|
|
143
|
+
return [
|
|
144
|
+
{ label: `Advisor ${index + 1} model`, value: advisorModelValue(advisor?.model) },
|
|
145
|
+
{ label: `Advisor ${index + 1} thinking`, value: thinkingValue(advisor?.thinking) },
|
|
146
|
+
];
|
|
147
|
+
}).flat(),
|
|
148
|
+
{ label: "Context protection", value: projection.context.mode },
|
|
149
|
+
{
|
|
150
|
+
label: "Context reader",
|
|
151
|
+
value: projection.context.readerEnabled ? "enabled" : "disabled",
|
|
152
|
+
},
|
|
153
|
+
{ label: "Context reader role", value: projection.context.readerRole },
|
|
154
|
+
{
|
|
155
|
+
label: "Context reader answer limit",
|
|
156
|
+
value: `${projection.context.answerMaxBytes} bytes`,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One `- label: previous -> current` line per changed field, in the canonical field order. */
|
|
162
|
+
export function changedValueLines(previous: NoticeProjection, current: NoticeProjection): string[] {
|
|
163
|
+
const advisorCount = Math.max(previous.advisors.length, current.advisors.length);
|
|
164
|
+
const before = projectionFields(previous, advisorCount);
|
|
165
|
+
const after = projectionFields(current, advisorCount);
|
|
166
|
+
return after.flatMap((field, index) => {
|
|
167
|
+
const previousValue = before[index]!.value;
|
|
168
|
+
return previousValue === field.value
|
|
169
|
+
? []
|
|
170
|
+
: [`- ${field.label}: ${previousValue} -> ${field.value}`];
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Lines the notice reports for one observed change. While both axes are already off a value edit
|
|
176
|
+
* changes nothing the model can act on, so only the transition into that state speaks, once.
|
|
177
|
+
*/
|
|
178
|
+
export function noticeLines(change: {
|
|
179
|
+
previous: NoticeProjection;
|
|
180
|
+
current: NoticeProjection;
|
|
181
|
+
previouslyOff: boolean;
|
|
182
|
+
currentlyOff: boolean;
|
|
183
|
+
}): string[] {
|
|
184
|
+
const lines = changedValueLines(change.previous, change.current);
|
|
185
|
+
if (!lines.length || !change.currentlyOff) return lines;
|
|
186
|
+
return change.previouslyOff ? [] : [`- ${BOTH_OFF_LINE}`];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function renderConfigNotice(lines: string[]): string {
|
|
190
|
+
return [
|
|
191
|
+
"<delegation_config_notice>",
|
|
192
|
+
"<!-- pi-delegation-policy:notice -->",
|
|
193
|
+
"This notice comes from the pi-delegation-policy extension, not from the user.",
|
|
194
|
+
"The /delegate configuration changed before this turn:",
|
|
195
|
+
...lines,
|
|
196
|
+
"The delegation policy in the system prompt, when present, states the values in force for the present request.",
|
|
197
|
+
"For new work, follow the current valid assignments and thinking rules in this policy, not earlier settings in the conversation.",
|
|
198
|
+
"This notice only records what changed in this extension's configuration and revokes no other instruction, permission, or authority.",
|
|
199
|
+
"</delegation_config_notice>",
|
|
200
|
+
].join("\n");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function createConfigNotice(lines: string[], timestamp: number): ConfigNoticeMessage {
|
|
204
|
+
return {
|
|
205
|
+
role: "custom",
|
|
206
|
+
customType: CONFIG_NOTICE_TYPE,
|
|
207
|
+
content: renderConfigNotice(lines),
|
|
208
|
+
display: false,
|
|
209
|
+
timestamp,
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
AgentActivityOutcome,
|
|
3
|
+
AgentEndEvent,
|
|
2
4
|
ExtensionAPI,
|
|
3
5
|
ExtensionCommandContext,
|
|
4
6
|
ExtensionContext,
|
|
7
|
+
InputSource,
|
|
5
8
|
} from "@earendil-works/pi-coding-agent";
|
|
6
9
|
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
7
10
|
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
@@ -12,8 +15,20 @@ import {
|
|
|
12
15
|
parseSessionState,
|
|
13
16
|
type GuardedAppendResult,
|
|
14
17
|
} from "./config.ts";
|
|
15
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
AdvisorExecutor,
|
|
20
|
+
advisorFailureText,
|
|
21
|
+
advisorThinking,
|
|
22
|
+
ADVISOR_AGENT_NAME,
|
|
23
|
+
} from "./advisor-executor.ts";
|
|
16
24
|
import { AdvisorLog } from "./advisor-log.ts";
|
|
25
|
+
import {
|
|
26
|
+
createConfigNotice,
|
|
27
|
+
noticeLines,
|
|
28
|
+
projectNoticeConfig,
|
|
29
|
+
type ConfigNoticeMessage,
|
|
30
|
+
type NoticeProjection,
|
|
31
|
+
} from "./config-notice.ts";
|
|
17
32
|
import { ContextShuntAdapter } from "./context-shunt-adapter.ts";
|
|
18
33
|
import { ContextShuntExecutor } from "./context-shunt-executor.ts";
|
|
19
34
|
import {
|
|
@@ -171,6 +186,28 @@ function isContextShuntDisabled(state: RuntimeState): boolean {
|
|
|
171
186
|
state.effective.contextShunt.suspended
|
|
172
187
|
);
|
|
173
188
|
}
|
|
189
|
+
function bothAxesOff(state: RuntimeState): boolean {
|
|
190
|
+
return state.effective.intensity === "off" && !isAdvisorEnabled(state.effective);
|
|
191
|
+
}
|
|
192
|
+
/** What the model has been told: the configuration shown by the last consumed notice. */
|
|
193
|
+
type ConfigReference = { projection: NoticeProjection; axesOff: boolean };
|
|
194
|
+
/**
|
|
195
|
+
* The host decides a turn's outcome from the last assistant message, and reports `completed` for
|
|
196
|
+
* every stop reason except an abort or an error. The same rule keeps the notice's consume rule
|
|
197
|
+
* aligned with the host's own notion of a finished turn.
|
|
198
|
+
*/
|
|
199
|
+
function endOutcome(messages: AgentEndEvent["messages"]): AgentActivityOutcome | undefined {
|
|
200
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
201
|
+
const message = messages[index];
|
|
202
|
+
if (message?.role !== "assistant") continue;
|
|
203
|
+
return message.stopReason === "aborted"
|
|
204
|
+
? "aborted"
|
|
205
|
+
: message.stopReason === "error"
|
|
206
|
+
? "error"
|
|
207
|
+
: "completed";
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
174
211
|
function synchronizeContextShunt(shunt: ContextShuntAdapter, state: RuntimeState): void {
|
|
175
212
|
if (isContextShuntDisabled(state)) shunt.clearPending();
|
|
176
213
|
}
|
|
@@ -379,6 +416,58 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
|
|
|
379
416
|
readerEpoch += 1;
|
|
380
417
|
executor.cancel();
|
|
381
418
|
};
|
|
419
|
+
// The notice follows the user's turns, not host turns. `input` reports where the next prompt
|
|
420
|
+
// comes from, `before_agent_start` opens one run per fresh prompt, and the comparison runs on
|
|
421
|
+
// that user turn's first request. A change made while a turn is already running updates the
|
|
422
|
+
// policy as usual and is reported on the next user turn, so no request carries a notice that
|
|
423
|
+
// contradicts the policy of the same request.
|
|
424
|
+
let configBaseline: ConfigReference | undefined;
|
|
425
|
+
let inputSource: InputSource | undefined;
|
|
426
|
+
let userTurn = 0;
|
|
427
|
+
let runToken = 0;
|
|
428
|
+
let diffedTurn: number | undefined;
|
|
429
|
+
let runOutcome: AgentActivityOutcome | undefined;
|
|
430
|
+
let presentedNotice:
|
|
431
|
+
{ runToken: number; reference: ConfigReference; message: ConfigNoticeMessage } | undefined;
|
|
432
|
+
const startConfigBaseline = (state: RuntimeState): void => {
|
|
433
|
+
configBaseline = {
|
|
434
|
+
projection: projectNoticeConfig(state.effective),
|
|
435
|
+
axesOff: bothAxesOff(state),
|
|
436
|
+
};
|
|
437
|
+
userTurn = 0;
|
|
438
|
+
runToken = 0;
|
|
439
|
+
diffedTurn = undefined;
|
|
440
|
+
runOutcome = undefined;
|
|
441
|
+
presentedNotice = undefined;
|
|
442
|
+
};
|
|
443
|
+
const configNotice = (state: RuntimeState): ConfigNoticeMessage | undefined => {
|
|
444
|
+
if (diffedTurn !== userTurn) {
|
|
445
|
+
diffedTurn = userTurn;
|
|
446
|
+
presentedNotice = undefined;
|
|
447
|
+
const current: ConfigReference = {
|
|
448
|
+
projection: projectNoticeConfig(state.effective),
|
|
449
|
+
axesOff: bothAxesOff(state),
|
|
450
|
+
};
|
|
451
|
+
// Every session start and tree move sets a baseline. Only an unreadable configuration
|
|
452
|
+
// defers it to this first successful refresh, which then reports nothing.
|
|
453
|
+
if (configBaseline === undefined) configBaseline = current;
|
|
454
|
+
else {
|
|
455
|
+
const lines = noticeLines({
|
|
456
|
+
previous: configBaseline.projection,
|
|
457
|
+
current: current.projection,
|
|
458
|
+
previouslyOff: configBaseline.axesOff,
|
|
459
|
+
currentlyOff: current.axesOff,
|
|
460
|
+
});
|
|
461
|
+
if (lines.length)
|
|
462
|
+
presentedNotice = {
|
|
463
|
+
runToken,
|
|
464
|
+
reference: current,
|
|
465
|
+
message: createConfigNotice(lines, Date.now()),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return presentedNotice?.runToken === runToken ? presentedNotice.message : undefined;
|
|
470
|
+
};
|
|
382
471
|
const rememberRuntime = (state: RuntimeState): RuntimeState => {
|
|
383
472
|
if (
|
|
384
473
|
readerAuthorization(latestRuntime)?.fingerprint !== readerAuthorization(state)?.fingerprint
|
|
@@ -497,9 +586,7 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
|
|
|
497
586
|
`${result.model} [${result.context}]:\n${
|
|
498
587
|
result.result.kind === "completed"
|
|
499
588
|
? result.result.value
|
|
500
|
-
: result.result
|
|
501
|
-
? "Fork unavailable. Call again with brief for a fresh consultation."
|
|
502
|
-
: `Advisor ${result.result.kind}.`
|
|
589
|
+
: advisorFailureText(result.result)
|
|
503
590
|
}`,
|
|
504
591
|
)
|
|
505
592
|
.join("\n\n") +
|
|
@@ -799,7 +886,7 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
|
|
|
799
886
|
});
|
|
800
887
|
pi.on("session_start", async (_event, ctx) => {
|
|
801
888
|
advisorContext = undefined;
|
|
802
|
-
await refreshRuntime(ctx);
|
|
889
|
+
startConfigBaseline(await refreshRuntime(ctx));
|
|
803
890
|
});
|
|
804
891
|
pi.on("session_tree", async (_event, ctx) => {
|
|
805
892
|
readerEpoch += 1;
|
|
@@ -807,7 +894,30 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
|
|
|
807
894
|
advisorExecutor.rotate();
|
|
808
895
|
shunt.clearPending();
|
|
809
896
|
advisorContext = undefined;
|
|
810
|
-
await refreshRuntime(ctx);
|
|
897
|
+
startConfigBaseline(await refreshRuntime(ctx));
|
|
898
|
+
});
|
|
899
|
+
pi.on("input", async (event) => {
|
|
900
|
+
// The host emits this for every submitted input immediately before the prompt it belongs to,
|
|
901
|
+
// and `before_agent_start` follows only for a fresh prompt. Attribution therefore depends on
|
|
902
|
+
// that order, which Pi 0.87.1 keeps for every path that reaches it; a host that emitted the
|
|
903
|
+
// anchor first would leave the previous source in place, so the notice would follow the
|
|
904
|
+
// earlier prompt instead of skipping an extension one. No public signal can detect that
|
|
905
|
+
// change, so it stays a documented residual.
|
|
906
|
+
inputSource = event.source;
|
|
907
|
+
});
|
|
908
|
+
pi.on("before_agent_start", async () => {
|
|
909
|
+
const source = inputSource;
|
|
910
|
+
inputSource = undefined;
|
|
911
|
+
runToken += 1;
|
|
912
|
+
runOutcome = undefined;
|
|
913
|
+
// Commands, input interception and tool iterations never reach this event, and the host
|
|
914
|
+
// emits it once per fresh prompt. A prompt an extension sends is not the user's turn, so it
|
|
915
|
+
// neither receives nor consumes a notice waiting for the user.
|
|
916
|
+
if (source === "extension") {
|
|
917
|
+
diffedTurn = userTurn;
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
userTurn += 1;
|
|
811
921
|
});
|
|
812
922
|
pi.on("context_with_system", async (event, ctx) => {
|
|
813
923
|
const state = await refreshRuntime(ctx);
|
|
@@ -819,7 +929,7 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
|
|
|
819
929
|
typeof systemMessage.content !== "string"
|
|
820
930
|
)
|
|
821
931
|
return undefined;
|
|
822
|
-
|
|
932
|
+
const notice = configNotice(state);
|
|
823
933
|
const ownedPolicyBlock =
|
|
824
934
|
/(?:\n\n)?<delegation_policy>\n<!-- pi-delegation-policy:owned -->[\s\S]*?<\/delegation_policy>/g;
|
|
825
935
|
let replacedPolicy = false;
|
|
@@ -853,15 +963,35 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
|
|
|
853
963
|
...(sections && Object.keys(sections).length > 0 ? { sections } : {}),
|
|
854
964
|
},
|
|
855
965
|
...event.messages.slice(1),
|
|
966
|
+
// Appended last, never inside the system block: a tool call stays adjacent to its result,
|
|
967
|
+
// the stored transcript prefix of the request is unchanged, and the host already sends
|
|
968
|
+
// user-role text after a tool result for its own custom messages.
|
|
969
|
+
...(notice ? [notice] : []),
|
|
856
970
|
],
|
|
857
971
|
};
|
|
858
972
|
});
|
|
973
|
+
pi.on("agent_settled", async () => {
|
|
974
|
+
// The notice is consumed when the last outcome the run reported was a completed response:
|
|
975
|
+
// `agent_end` reports one outcome per low-level run, and this event marks the end of the whole
|
|
976
|
+
// run, emitted once in the host's `finally` after retries, compaction and queued continuations.
|
|
977
|
+
// A completed settle boundary can still be followed by another request, or by an aborted one,
|
|
978
|
+
// so the last recorded outcome decides and an unknown one never consumes. Accepted residual:
|
|
979
|
+
// an abort or a throw after the last completed response, which the host reports through no
|
|
980
|
+
// later public event, leaves that `completed` standing and consumes the notice.
|
|
981
|
+
if (runOutcome !== "completed" || presentedNotice?.runToken !== runToken) return;
|
|
982
|
+
// The revision the model received, not the live one: a change made after that request stays
|
|
983
|
+
// pending for the next turn.
|
|
984
|
+
configBaseline = presentedNotice.reference;
|
|
985
|
+
});
|
|
859
986
|
pi.on("before_provider_request", async (event, ctx) => {
|
|
860
987
|
const state = await refreshProviderRuntime(ctx);
|
|
861
988
|
if (!state) return event.payload;
|
|
862
989
|
return updateProviderPolicy(event.payload, renderPolicy(ctx, state));
|
|
863
990
|
});
|
|
864
|
-
pi.on("agent_end", async () => {
|
|
991
|
+
pi.on("agent_end", async (event) => {
|
|
992
|
+
// The event carries the messages of this low-level run; a run that reports none has no
|
|
993
|
+
// outcome to trust, and an unknown outcome never consumes a notice.
|
|
994
|
+
runOutcome = endOutcome(event.messages ?? []);
|
|
865
995
|
shunt.clearConsumed();
|
|
866
996
|
});
|
|
867
997
|
pi.on("tool_call", async (event) => {
|
|
@@ -920,6 +1050,9 @@ export function createPiDelegationPolicy(options: PiDelegationPolicyOptions = {}
|
|
|
920
1050
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
921
1051
|
readerEpoch += 1;
|
|
922
1052
|
latestRuntime = undefined;
|
|
1053
|
+
configBaseline = undefined;
|
|
1054
|
+
presentedNotice = undefined;
|
|
1055
|
+
runOutcome = undefined;
|
|
923
1056
|
executor.close();
|
|
924
1057
|
advisorExecutor.close();
|
|
925
1058
|
await advisorLog.close();
|