@aeondave/pi-persona 1.16.2 → 1.17.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 +34 -0
- package/README.md +7 -2
- package/docs/ARCHITECTURE.md +13 -0
- package/docs/REFERENCE.md +23 -3
- package/docs/TELEMETRY.md +8 -0
- package/package.json +2 -2
- package/src/engine/adapter.ts +4 -2
- package/src/engine/errors.ts +1 -0
- package/src/engine/inproc.ts +4 -2
- package/src/engine/spec-preflight.ts +13 -0
- package/src/extension/hooks.ts +47 -12
- package/src/extension.ts +109 -13
- package/src/persona/controller.ts +16 -3
- package/src/telemetry/contract.ts +7 -5
- package/src/tools/delegate.ts +3 -1
- package/src/tools/exocom.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ used in its [GitHub releases](https://github.com/AeonDave/pi-persona/releases).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.17.0](https://github.com/AeonDave/pi-persona/releases/tag/v1.17.0) - 2026-10-19
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `/persona models [council-persona]` refreshes the host catalog and reopens model selection for
|
|
14
|
+
the active caller's council participants, preserving cancelled choices, unrelated assignments,
|
|
15
|
+
and explicit inline model pins. No supervisor switch or model request is required.
|
|
16
|
+
- A hermetic installed-host Pi 1.3.0 qualification script covers real command dispatch, assignment
|
|
17
|
+
persistence, custom-message starts and missing-auth council recovery without network requests.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Publish terminal progress for single delegates before removing their tree node, preserving `done`
|
|
22
|
+
status in Flow and other telemetry consumers.
|
|
23
|
+
- Preserve Exocom reply references and optional target-correlated transport IDs while keeping each
|
|
24
|
+
queued/terminal telemetry row stable; retain bounded broadcast delivery receipts.
|
|
25
|
+
- Report an exact acknowledged broadcast receipt as delivered even when failures for other peers
|
|
26
|
+
exceed the retained sample; recipients without exact evidence remain conservatively unknown.
|
|
27
|
+
- Classify Pi's `No API key found for <provider>` diagnostic as a provider failure in both engines,
|
|
28
|
+
allowing the existing single main-model recovery for councils/strategies/flows. Ordinary delegate
|
|
29
|
+
provider pins remain strict; aborts, timeouts, contract and agent failures remain terminal.
|
|
30
|
+
- Report a refused persona model switch instead of silently recording an override when Pi rejects
|
|
31
|
+
it for missing authentication; keep the actual session model and restoration state intact.
|
|
32
|
+
- Remove a timing race from the supervisor-ask telemetry test: verify the persisted timeout
|
|
33
|
+
transition history rather than polling a transient 20 ms state through asynchronous disk writes.
|
|
34
|
+
|
|
35
|
+
## [1.16.3](https://github.com/AeonDave/pi-persona/releases/tag/v1.16.3) - 2026-10-13
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Reject Pi `+name`/`-name` tool modifiers in agent and per-run allowlists before either engine
|
|
40
|
+
starts. Those selectors are literal allowlist entries in pi-persona; Pi 1.2 instead applies them
|
|
41
|
+
to its default tools. Use `deny` or `excludeTools` for denials.
|
|
42
|
+
|
|
9
43
|
## [1.16.2](https://github.com/AeonDave/pi-persona/releases/tag/v1.16.2) - 2026-10-08
|
|
10
44
|
|
|
11
45
|
### Fixed
|
package/README.md
CHANGED
|
@@ -39,9 +39,9 @@ your customizations.
|
|
|
39
39
|
| Install option | Command |
|
|
40
40
|
|---|---|
|
|
41
41
|
| This project only | `pi install -l npm:@aeondave/pi-persona` |
|
|
42
|
-
| Pin this release | `pi install npm:@aeondave/pi-persona@1.
|
|
42
|
+
| Pin this release | `pi install npm:@aeondave/pi-persona@1.17.0` |
|
|
43
43
|
| Update an unpinned install | `pi update npm:@aeondave/pi-persona` |
|
|
44
|
-
| Git alternative | `pi install git:github.com/AeonDave/pi-persona@v1.
|
|
44
|
+
| Git alternative | `pi install git:github.com/AeonDave/pi-persona@v1.17.0` |
|
|
45
45
|
|
|
46
46
|
Restart Pi after updating. For a pinned install, install the new version or tag explicitly.
|
|
47
47
|
If switching from a Git install, remove that source first with
|
|
@@ -91,6 +91,7 @@ the worker has acted on it.
|
|
|
91
91
|
| `/peek` | Show a compact progress summary |
|
|
92
92
|
| `/persona list` | List installed personas |
|
|
93
93
|
| `/persona off` | Turn off the active persona |
|
|
94
|
+
| `/persona models [council-persona]` | Refresh the catalog and reselect council models for the active persona |
|
|
94
95
|
| `/models <query>` | Find an available model |
|
|
95
96
|
| `/doctor` | Check configuration and available capabilities |
|
|
96
97
|
|
|
@@ -151,6 +152,8 @@ Strategies are not restricted to the similarly named persona: `council` can sele
|
|
|
151
152
|
custom role-based groups without team files. Model selection includes external arbiters. In councils
|
|
152
153
|
and flows, a provider failure or unavailable model can recover once on the supervisor's model, with
|
|
153
154
|
the switch visible and both attempts accounted; ordinary delegates keep explicit provider pins strict.
|
|
155
|
+
To replace old council assignments without changing supervisors, run `/persona models magi`
|
|
156
|
+
(or `/persona models` for the active council). Cancel a choice to keep its previous value.
|
|
154
157
|
See [the reference](docs/REFERENCE.md) for an inline council example and recovery limits.
|
|
155
158
|
|
|
156
159
|
## Make it yours
|
|
@@ -187,6 +190,8 @@ npm test
|
|
|
187
190
|
```
|
|
188
191
|
|
|
189
192
|
Pi loads the TypeScript extension directly; there is no build step.
|
|
193
|
+
To qualify a separately installed Pi 1.3.0 host without cloud calls or your profile, run
|
|
194
|
+
`node --import tsx scripts/qualify-host.mjs <absolute-path-to-pi-coding-agent-package>`.
|
|
190
195
|
The [CI workflow](https://github.com/AeonDave/pi-persona/actions/workflows/test.yml) runs strict
|
|
191
196
|
typechecking, dependency auditing and the test suite on Linux and Windows. The OpenSSF badge links to the project's
|
|
192
197
|
scored supply-chain checks; it is not a certification that the code is free of vulnerabilities.
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -300,6 +300,19 @@ inline model pins taking precedence. If distinct full roles share a short displa
|
|
|
300
300
|
keeps the hint (with `#N` suffixes for repeated node keys) but omits the uncertain model label rather
|
|
301
301
|
than borrowing another role's choice. Routing still resolves the full role key, not the display hint.
|
|
302
302
|
|
|
303
|
+
**Model assignment repair is configuration, not execution recovery.** `/persona models [profile]`
|
|
304
|
+
is an explicit user edit in the Pi adapter: it reuses `resolveCouncilInvocation`,
|
|
305
|
+
`collectModelParticipants` and the existing locked config writer, without adding another roster,
|
|
306
|
+
model store or provider-routing policy. The active caller owns saved assignments even when borrowing
|
|
307
|
+
another council profile. Automatic discovery stays never-nag; explicit editing may replace saved or
|
|
308
|
+
agent defaults, but not inline pins. Host catalog refresh has a 20-second cooperative deadline;
|
|
309
|
+
reported cancellation/errors or a caller/session change leave assignments untouched. Selection uses
|
|
310
|
+
the refreshed host availability snapshot rather than treating all catalog entries as authenticated.
|
|
311
|
+
Cancelled rows preserve their old choices; accepted choices merge without clearing other personas.
|
|
312
|
+
Running engines retain their captured assignments. Missing credentials are classified at the engine
|
|
313
|
+
adapter boundary; only the SDK decides whether a main-model recovery is admissible. This separation
|
|
314
|
+
avoids a global reset or automatic repinning that could silently change billing and panel diversity.
|
|
315
|
+
|
|
303
316
|
### MCP (and other `session_start`-scoped extensions) in sub-agents
|
|
304
317
|
|
|
305
318
|
**A sub-agent does NOT share the supervisor's MCP session, and an in-process sub-agent gets NO MCP at
|
package/docs/REFERENCE.md
CHANGED
|
@@ -189,9 +189,11 @@ files are seed sources. A fresh install has no installed personas until `/person
|
|
|
189
189
|
| Preset | `presets/*.preset.json` | reusable council strategy, roster, and parameter bundle |
|
|
190
190
|
|
|
191
191
|
`tools` accepts an allowlist such as `[read, grep]`, an `allow`/`deny` object, or `[]` for no tools.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
Entries are plain tool names or `*` patterns; Pi's `+name`/`-name` default-tool modifier
|
|
193
|
+
syntax is not an allowlist and is rejected. Use the `deny` field to remove tools. Omit `tools` to
|
|
194
|
+
inherit the session tool set. `model`, `isolation: worktree`,
|
|
195
|
+
`mcp: true`, and `purpose` are optional agent metadata. The body is the agent prompt. Inline roster
|
|
196
|
+
members specialise one agent with a role, model, or skills without requiring another agent file.
|
|
195
197
|
|
|
196
198
|
### Multi-agent quick start
|
|
197
199
|
|
|
@@ -301,10 +303,28 @@ With an active persona, UI, and at least two configured models, unassigned parti
|
|
|
301
303
|
picked and saved for the active persona, even when borrowing another persona's council profile.
|
|
302
304
|
Dismissing a picker suppresses another prompt for that participant during the session, not for
|
|
303
305
|
later teams with different participants. Headless runs never prompt.
|
|
306
|
+
|
|
307
|
+
Run `/persona models` to explicitly reselect the active council's models, or `/persona models magi`
|
|
308
|
+
to edit the MAGI panel while keeping another supervisor active. The optional name selects an
|
|
309
|
+
installed council persona, not a strategy or team. The command refreshes the host catalog first,
|
|
310
|
+
then offers each editable participant, including previously dismissed choices and saved or agent
|
|
311
|
+
defaults. One available model is enough for this explicit edit. Choices are saved under the **active
|
|
312
|
+
caller's** persona, just as for `council({ persona: "magi" })`; it never activates the borrowed
|
|
313
|
+
profile. Cancelled choices, unrelated assignments and other personas remain unchanged. Inline
|
|
314
|
+
`members[].model` pins still win and are not editable here. TUI/RPC UI is required; no model call
|
|
315
|
+
is made. Changes apply to future runs, not workers already running. `/persona magi` still switches
|
|
316
|
+
the supervisor; `/persona reload` reloads definitions without resetting saved models.
|
|
317
|
+
Refresh is bounded to 20 seconds cooperatively; provider errors, cancellation, or a persona/session
|
|
318
|
+
change cancel the edit without replacing assignments. The list uses the host's refreshed availability,
|
|
319
|
+
not every model in its catalog.
|
|
320
|
+
|
|
304
321
|
An unavailable choice is not overwritten: a provider or unknown-model failure may recover once
|
|
305
322
|
on the main model, with the switch reported and both attempts accounted. This does not relax
|
|
306
323
|
ordinary delegate provider pins. The MAGI persona retains its canonical core team; the Judge
|
|
307
324
|
persona retains impartial arbitration. Other personas choose whichever strategy fits the task.
|
|
325
|
+
The exact Pi diagnostic `No API key found for <provider>` counts as a provider failure in both
|
|
326
|
+
engines, so it can use this same bounded recovery. If the main model is the failing model too,
|
|
327
|
+
recovery cannot help: use `/login` or change `/model`, then reselect with `/persona models`.
|
|
308
328
|
|
|
309
329
|
**A delegation policy on any persona** — fields are generic and are never keyed to a persona name.
|
|
310
330
|
The runtime rejects incomplete briefs before a model call, supplies a missing contract, rejects
|
package/docs/TELEMETRY.md
CHANGED
|
@@ -8,6 +8,14 @@ waiting from tool arguments, output text or a quiet clock.
|
|
|
8
8
|
|
|
9
9
|
Telemetry is a vendor-neutral v2 envelope. `producerId` and `producerVersion` identify the emitting plugin; `version` identifies the wire schema. `seq` is monotonic only within `(producerId, sessionId)`, and `id` is deduplicated within that same scope.
|
|
10
10
|
|
|
11
|
+
Message payload `id` is the stable lifecycle-row key: queued and terminal updates keep it unchanged.
|
|
12
|
+
Optional `wireId` identifies the actual transport message, and `replyTo` references that transport's
|
|
13
|
+
original ID. Exocom sends attach `wireId` only after an exact target-correlated successful receipt;
|
|
14
|
+
broadcast receipts are bounded and omitted mappings remain unknown. Do not replace the queued row's
|
|
15
|
+
`id` with a wire ID, infer a mapping by timestamp/alias, or merge observations from different streams.
|
|
16
|
+
Single delegates publish their terminal progress before tree removal, so a completed worker remains
|
|
17
|
+
`done` in the telemetry instead of being mistaken for a stopped in-flight leg.
|
|
18
|
+
|
|
11
19
|
The pi-persona producer writes semantic projections under `telemetry/v2/<workspace>/<producerId>/`. `<workspace>` always identifies the Pi process's actual cwd; joining another Exocom scope does not rewrite telemetry identity. It never persists task/prompt text, tool arguments/activity, model output, paths, secrets, or message bodies. Known pi-persona event payloads are allowlisted; an unreviewed producer event retains only its envelope/type (payload `{}`) at the producer and consumer boundary. Future producers must add a reviewed allowlist/schema before publishing data intended for a consumer.
|
|
12
20
|
|
|
13
21
|
JSONL compaction is atomic and hysteretic. It preserves the initial `instance.started` anchor and complete records; the configured byte limit is therefore a soft bound for an anchor plus one complete event. The `.previous` backup is recovered after an interrupted swap.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aeondave/pi-persona",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Supervised multi-agent orchestration for Pi — async workers, live steering, cross-session collaboration, and switchable personas.",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
"typescript": "^7.0.2"
|
|
105
105
|
},
|
|
106
106
|
"pi": {
|
|
107
|
-
"image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.
|
|
107
|
+
"image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.17.0/assets/banner.png",
|
|
108
108
|
"video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
|
|
109
109
|
"extensions": [
|
|
110
110
|
"./src/extension.ts"
|
package/src/engine/adapter.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { type ChildEngineOptions, type ChildRunSpec, runChildAgent } from "./chi
|
|
|
18
18
|
import { looksLikeProviderError } from "./errors.ts";
|
|
19
19
|
import { nextChildHandle } from "./handles.ts";
|
|
20
20
|
import { combineSignals } from "./signals.ts";
|
|
21
|
-
import { unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
21
|
+
import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
22
22
|
import type { ToolEvent } from "./stream.ts";
|
|
23
23
|
|
|
24
24
|
/** The engine-side face of the cross-process broker (spec B3/B4/B7) — a child-engine
|
|
@@ -113,6 +113,9 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
113
113
|
): Promise<AgentResult> {
|
|
114
114
|
const cfg = deps.resolveAgent(spec.agent);
|
|
115
115
|
if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
|
|
116
|
+
const tools = spec.tools ?? cfg.tools;
|
|
117
|
+
const invalidTools = toolModifierFailure(spec.agent, tools);
|
|
118
|
+
if (invalidTools) return invalidTools;
|
|
116
119
|
|
|
117
120
|
// Resolved (and pinned) up front: the SAME def both instructs the member and
|
|
118
121
|
// validates its output — an instruction/validation drift is impossible. A named
|
|
@@ -132,7 +135,6 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
|
|
|
132
135
|
// a child without it defaults to "adaptive", which some models reject.
|
|
133
136
|
if (model && deps.childThinking && !model.includes(":")) model = `${model}:${deps.childThinking}`;
|
|
134
137
|
if (model) childSpec.model = model;
|
|
135
|
-
const tools = spec.tools ?? cfg.tools;
|
|
136
138
|
if (tools !== undefined) childSpec.tools = tools;
|
|
137
139
|
if (cfg.excludeTools && cfg.excludeTools.length > 0) childSpec.excludeTools = cfg.excludeTools;
|
|
138
140
|
// The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
|
package/src/engine/errors.ts
CHANGED
|
@@ -19,6 +19,7 @@ const PROVIDER_PATTERNS: readonly RegExp[] = [
|
|
|
19
19
|
/\brate.?limit|\b429\b|too many requests/i,
|
|
20
20
|
/\bquota\b|billing|insufficient.?credits/i,
|
|
21
21
|
/\b401\b|\b403\b|unauthorized|invalid[\s_-]?api[\s_-]?key/i,
|
|
22
|
+
/\bno api key found for [a-z0-9][a-z0-9._-]*\b/i,
|
|
22
23
|
/\b5\d\d\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded|capacity/i,
|
|
23
24
|
/model.{0,24}(not.{0,8}(supported|found|exist)|does not exist)|unsupported.{0,12}model/i,
|
|
24
25
|
/\beconnreset\b|\betimedout\b|\beconnrefused\b|socket hang up|fetch failed/i,
|
package/src/engine/inproc.ts
CHANGED
|
@@ -35,7 +35,7 @@ import type { AgentResult } from "../orchestration/types.ts";
|
|
|
35
35
|
import { looksLikeProviderError } from "./errors.ts";
|
|
36
36
|
import { nextChildHandle } from "./handles.ts";
|
|
37
37
|
import { combineSignals } from "./signals.ts";
|
|
38
|
-
import { unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
38
|
+
import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
|
|
39
39
|
import { applyEvent, createStreamState, emptyUsage, type ProgressSnapshot, snapshot } from "./stream.ts";
|
|
40
40
|
|
|
41
41
|
/** The minimal slice of Pi's AgentSession the engine drives — also the test seam. */
|
|
@@ -315,6 +315,9 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
315
315
|
async run(spec: AgentRunSpec, onProgress?, callSignal?, onSteerable?): Promise<AgentResult> {
|
|
316
316
|
const cfg = deps.resolveAgent(spec.agent);
|
|
317
317
|
if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
|
|
318
|
+
const tools = spec.tools ?? cfg.tools;
|
|
319
|
+
const invalidTools = toolModifierFailure(spec.agent, tools);
|
|
320
|
+
if (invalidTools) return invalidTools;
|
|
318
321
|
// A requested contract is a runtime guard, not merely prompt decoration. Resolve it
|
|
319
322
|
// before model/session construction so a missing name can never run unconstrained.
|
|
320
323
|
const requestedContract = spec.outputContract?.trim();
|
|
@@ -346,7 +349,6 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
|
|
|
346
349
|
const rm = model as { provider?: string; id?: string };
|
|
347
350
|
const resolvedRef = rm.provider && rm.id ? `${rm.provider}/${rm.id}` : ref;
|
|
348
351
|
const thinkingLevel = isThinkingLevel(deps.childThinking) ? deps.childThinking : undefined;
|
|
349
|
-
const tools = spec.tools ?? cfg.tools;
|
|
350
352
|
const excludeTools = cfg.excludeTools;
|
|
351
353
|
|
|
352
354
|
// Diagnostic tag: which agent + which model ref + whether it was dynamically
|
|
@@ -31,3 +31,16 @@ export function unknownAgentFailure(agent: string, installed: string[]): AgentRe
|
|
|
31
31
|
export function unknownContractFailure(agent: string, requested: string, installed: string[]): AgentResult {
|
|
32
32
|
return failure(agent, `[${agent}] output contract "${requested}" not found${installedHint("contracts", installed)}`, "contract");
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
/** Pi's SDK treats leading `+`/`-` entries in `tools` as default-tool modifiers, not as
|
|
36
|
+
* literal allowlist entries. AgentRunSpec.tools and agent frontmatter use this field as an
|
|
37
|
+
* allowlist, so reject that syntax before either engine reaches Pi. Use excludeTools for denies. */
|
|
38
|
+
export function toolModifierFailure(agent: string, tools: readonly string[] | undefined): AgentResult | undefined {
|
|
39
|
+
const modifier = tools?.find((selector) => /^[+-]/.test(selector.trim()));
|
|
40
|
+
if (modifier === undefined) return undefined;
|
|
41
|
+
return failure(
|
|
42
|
+
agent,
|
|
43
|
+
`[${agent}] unsupported tool modifier "${modifier.trim()}" in tools allowlist; use plain tool names/patterns or excludeTools for denials`,
|
|
44
|
+
"contract",
|
|
45
|
+
);
|
|
46
|
+
}
|
package/src/extension/hooks.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface HookHost {
|
|
|
61
61
|
get telemetry(): TelemetryProducer | undefined;
|
|
62
62
|
set telemetry(value: TelemetryProducer | undefined);
|
|
63
63
|
readonly telemetryToolStartedAt: Map<string, { startedAt: number; name: string }>;
|
|
64
|
-
readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number }>>;
|
|
64
|
+
readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number; replyTo?: string }>>;
|
|
65
65
|
readonly telemetryIntercomPending: Map<string, { kind: string; to: string; size: number; replyTo?: string }>;
|
|
66
66
|
readonly lastAgentProjection: Map<string, string>;
|
|
67
67
|
readonly telemetryAskSenders: Map<string, string>;
|
|
@@ -306,6 +306,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
306
306
|
status: "failed",
|
|
307
307
|
expectsReply: false,
|
|
308
308
|
size: message.size,
|
|
309
|
+
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
309
310
|
});
|
|
310
311
|
}
|
|
311
312
|
h.telemetryExocomPending.clear();
|
|
@@ -557,6 +558,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
557
558
|
if (event.toolName === "exocom_send") {
|
|
558
559
|
const text = typeof input.message === "string" ? input.message : "";
|
|
559
560
|
const target = typeof input.target === "string" ? input.target : "";
|
|
561
|
+
const replyTo = typeof input.in_reply_to === "string" && input.in_reply_to.length > 0 ? input.in_reply_to : undefined;
|
|
560
562
|
let peers: DisplayPeer[] = [];
|
|
561
563
|
try { peers = exocom.plane?.listPeers() ?? []; } catch { /* h.telemetry cannot break the tool */ }
|
|
562
564
|
const sessions = canonicalExocomTelemetryTargets(peers, target);
|
|
@@ -566,6 +568,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
566
568
|
sessionId,
|
|
567
569
|
target: target === "*" ? (peers.find((peer) => peer.session_id === sessionId)?.target ?? sessionId) : target,
|
|
568
570
|
size,
|
|
571
|
+
...(replyTo ? { replyTo } : {}),
|
|
569
572
|
}));
|
|
570
573
|
if (pending.length > 0) h.telemetryExocomPending.set(event.toolCallId, pending);
|
|
571
574
|
for (const message of pending) h.telemetry?.publish("message.sent", {
|
|
@@ -577,6 +580,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
577
580
|
status: "queued",
|
|
578
581
|
expectsReply: false,
|
|
579
582
|
size: message.size,
|
|
583
|
+
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
580
584
|
});
|
|
581
585
|
}
|
|
582
586
|
return undefined;
|
|
@@ -606,8 +610,24 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
606
610
|
const pending = h.telemetryExocomPending.get(event.toolCallId) ?? [];
|
|
607
611
|
h.telemetryExocomPending.delete(event.toolCallId);
|
|
608
612
|
const details = event.details && typeof event.details === "object" && !Array.isArray(event.details)
|
|
609
|
-
? event.details as {
|
|
613
|
+
? event.details as {
|
|
614
|
+
target?: unknown;
|
|
615
|
+
msg_id?: unknown;
|
|
616
|
+
sent?: unknown;
|
|
617
|
+
failed?: Array<{ target?: unknown }>;
|
|
618
|
+
failedCount?: unknown;
|
|
619
|
+
omittedFailures?: unknown;
|
|
620
|
+
}
|
|
610
621
|
: undefined;
|
|
622
|
+
const receipts = Array.isArray(details?.sent)
|
|
623
|
+
? details.sent.flatMap((receipt) => {
|
|
624
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return [];
|
|
625
|
+
const item = receipt as { target?: unknown; msg_id?: unknown };
|
|
626
|
+
return typeof item.target === "string" && typeof item.msg_id === "string" && item.msg_id.length > 0
|
|
627
|
+
? [{ target: item.target, msgId: item.msg_id }]
|
|
628
|
+
: [];
|
|
629
|
+
})
|
|
630
|
+
: [];
|
|
611
631
|
const sampledFailures = details?.failed ?? [];
|
|
612
632
|
const failedTargets = new Set(sampledFailures.flatMap((failure) => typeof failure.target === "string" ? [failure.target] : []));
|
|
613
633
|
// A broadcast samples its failures (`MAX_BROADCAST_DETAIL_ITEMS` in tools/exocom.ts) and
|
|
@@ -618,16 +638,31 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
|
|
|
618
638
|
typeof details?.omittedFailures === "number" ? details.omittedFailures : 0,
|
|
619
639
|
declaredFailures - sampledFailures.length,
|
|
620
640
|
);
|
|
621
|
-
for (const message of pending)
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
641
|
+
for (const message of pending) {
|
|
642
|
+
const matchingReceipts = !event.isError && details?.target === "*"
|
|
643
|
+
? receipts.filter((receipt) => receipt.target === message.target)
|
|
644
|
+
: [];
|
|
645
|
+
const failed = event.isError || failedTargets.has(message.target);
|
|
646
|
+
const wireId = failed ? undefined
|
|
647
|
+
: details?.target === message.target && typeof details.msg_id === "string" && details.msg_id.length > 0
|
|
648
|
+
? details.msg_id
|
|
649
|
+
: matchingReceipts.length === 1 ? matchingReceipts[0]!.msgId : undefined;
|
|
650
|
+
h.telemetry?.publish("message.sent", {
|
|
651
|
+
id: message.id,
|
|
652
|
+
channel: "exocom",
|
|
653
|
+
from: exocom.telemetrySessionId(ctx),
|
|
654
|
+
to: message.sessionId,
|
|
655
|
+
kind: "message",
|
|
656
|
+
// A matching successful ACK receipt settles this recipient even when the bounded
|
|
657
|
+
// broadcast failure sample omitted failures for other peers. Without a receipt, an
|
|
658
|
+
// incomplete failure sample cannot prove this particular row was delivered.
|
|
659
|
+
status: failed ? "failed" : omittedFailures > 0 && wireId === undefined ? "unknown" : "delivered",
|
|
660
|
+
expectsReply: false,
|
|
661
|
+
size: message.size,
|
|
662
|
+
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
663
|
+
...(wireId ? { wireId } : {}),
|
|
664
|
+
});
|
|
665
|
+
}
|
|
631
666
|
}
|
|
632
667
|
if (event.toolName === "intercom") {
|
|
633
668
|
const action = typeof telemetryInput.action === "string" ? telemetryInput.action : "";
|
package/src/extension.ts
CHANGED
|
@@ -108,7 +108,7 @@ import {
|
|
|
108
108
|
pendingParticipants,
|
|
109
109
|
type ModelParticipant,
|
|
110
110
|
} from "./persona/model-participants.ts";
|
|
111
|
-
import { expandCouncilPreset, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
|
|
111
|
+
import { expandCouncilPreset, resolveCouncilInvocation, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
|
|
112
112
|
import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
|
|
113
113
|
import { readLastPersona, writeLastPersona } from "./persona/state.ts";
|
|
114
114
|
import { type AgentKind as TelemetryAgentKind, type AgentStatus as TelemetryAgentStatus } from "./telemetry/contract.ts";
|
|
@@ -549,7 +549,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
549
549
|
// rather than to a "subagent" literal that names nobody. Bounded; cleared on teardown.
|
|
550
550
|
const telemetryAskSenders = new Map<string, string>();
|
|
551
551
|
const MAX_TRACKED_ASK_SENDERS = 256;
|
|
552
|
-
type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number };
|
|
552
|
+
type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number; replyTo?: string };
|
|
553
553
|
const telemetryExocomPending = new Map<string, PendingExocomTelemetry[]>();
|
|
554
554
|
// …and the same for intercom: both channels open at tool_call and close at tool_result, so both are
|
|
555
555
|
// left hanging when Pi resolves a call as "immediate" and never reaches a result.
|
|
@@ -1013,9 +1013,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1013
1013
|
try {
|
|
1014
1014
|
const all = lastCtx?.modelRegistry.getAll() ?? [];
|
|
1015
1015
|
const m = all.find((x) => x.provider === handle.provider && x.id === handle.id);
|
|
1016
|
-
|
|
1016
|
+
return m ? await pi.setModel(m) : false;
|
|
1017
1017
|
} catch {
|
|
1018
|
-
|
|
1018
|
+
return false; // keep current, but let the controller report that the switch failed
|
|
1019
1019
|
}
|
|
1020
1020
|
},
|
|
1021
1021
|
setStatus: (text) => {
|
|
@@ -1383,16 +1383,56 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1383
1383
|
* authenticated model to choose between.
|
|
1384
1384
|
*/
|
|
1385
1385
|
async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
|
|
1386
|
-
const
|
|
1387
|
-
if (!
|
|
1388
|
-
const missing = pendingParticipants(participants, { prompted: promptedFor(persona, participants) });
|
|
1386
|
+
const scope = modelSelectionScope(ctx);
|
|
1387
|
+
if (!scope || !ctx.hasUI) return;
|
|
1388
|
+
const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
|
|
1389
1389
|
if (missing.length === 0) return;
|
|
1390
1390
|
const available = configuredModels(ctx);
|
|
1391
|
-
if (available.length < 2) return; //
|
|
1391
|
+
if (available.length < 2) return; // automatic setup cannot diversify a single model
|
|
1392
|
+
const chosen = await choosePersonaModels(ctx, missing, available, scope);
|
|
1393
|
+
if (chosen && Object.keys(chosen).length > 0) savePersonaModels(ctx, scope.persona, chosen);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
type ModelSelectionScope = { persona: string; isCurrent(): boolean };
|
|
1397
|
+
function modelSelectionScope(ctx: ExtensionContext): ModelSelectionScope | undefined {
|
|
1398
|
+
const active = controller.activePersona;
|
|
1399
|
+
if (!active) return undefined;
|
|
1400
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
1401
|
+
return { persona: active.name, isCurrent: () => !disposed && controller.activePersona === active &&
|
|
1402
|
+
(sessionId === undefined || lastCtx?.sessionManager?.getSessionId?.() === sessionId) };
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** An explicit user edit is allowed to replace defaults, unlike automatic first-run setup. */
|
|
1406
|
+
async function editPersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], scope: ModelSelectionScope): Promise<void> {
|
|
1407
|
+
const editable = participants.filter((p) => p.source !== "inline");
|
|
1408
|
+
if (editable.length === 0) {
|
|
1409
|
+
ctx.ui.notify("persona models: no editable council participants (inline model pins are unchanged).", "info");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
// Trust fresh availability here, not the discovery union with provisional catalog entries.
|
|
1413
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
1414
|
+
if (available.length === 0) {
|
|
1415
|
+
ctx.ui.notify("persona models: no available models. Use /login, then retry /persona models.", "warning");
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const chosen = await choosePersonaModels(ctx, editable, available, scope);
|
|
1419
|
+
if (!chosen) return;
|
|
1420
|
+
if (Object.keys(chosen).length === 0) {
|
|
1421
|
+
ctx.ui.notify("persona models: no model assignments changed; previous choices kept.", "info");
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (savePersonaModels(ctx, scope.persona, chosen)) {
|
|
1425
|
+
ctx.ui.notify(`persona models: updated ${Object.keys(chosen).length} model assignment(s) for "${scope.persona}"; applies to future runs.`, "info");
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
|
|
1430
|
+
async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available: Array<{ provider: string; id: string }>, scope: ModelSelectionScope): Promise<Record<string, string> | undefined> {
|
|
1431
|
+
const { persona } = scope;
|
|
1392
1432
|
const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
|
|
1393
1433
|
const chosen: Record<string, string> = {};
|
|
1394
1434
|
try {
|
|
1395
|
-
for (const p of
|
|
1435
|
+
for (const p of participants) {
|
|
1396
1436
|
// Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
|
|
1397
1437
|
// for this session, exactly like an answer is not asked twice.
|
|
1398
1438
|
promptedParticipants.add(`${persona}\0${p.key}`);
|
|
@@ -1403,7 +1443,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1403
1443
|
const role = p.param ? ` · ${p.param}` : "";
|
|
1404
1444
|
// A participant label carries a role prompt (free prose from a team file or a
|
|
1405
1445
|
// caller) — sanitised here so a title can never repaint the TUI or forge a row.
|
|
1406
|
-
const
|
|
1446
|
+
const current = p.assigned && p.model ? ` · current: ${p.model}` : "";
|
|
1447
|
+
const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}${current}`, { maxChars: 200 });
|
|
1407
1448
|
// In the TUI: a searchable picker (type to filter) whose viewport follows the
|
|
1408
1449
|
// selection — the built-in select can't scroll a hundreds-long provider list
|
|
1409
1450
|
// usefully. Outside the TUI (RPC), fall back to the built-in select.
|
|
@@ -1415,12 +1456,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1415
1456
|
{ overlay: true },
|
|
1416
1457
|
)
|
|
1417
1458
|
: await ctx.ui.select(title, options);
|
|
1418
|
-
if (
|
|
1459
|
+
if (!scope.isCurrent()) {
|
|
1460
|
+
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
if (pick && options.includes(pick)) chosen[p.key] = pick;
|
|
1419
1464
|
}
|
|
1420
1465
|
} catch {
|
|
1421
1466
|
/* dismissed / no UI → fall back to the default model */
|
|
1422
1467
|
}
|
|
1423
|
-
if (
|
|
1468
|
+
if (!scope.isCurrent()) {
|
|
1469
|
+
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
return chosen;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function savePersonaModels(ctx: ExtensionContext, persona: string, chosen: Record<string, string>): boolean {
|
|
1424
1476
|
// The picks are live for this session either way (the in-memory store is updated first), so
|
|
1425
1477
|
// a save failure costs only PERSISTENCE — but silently swallowing it would re-prompt on every
|
|
1426
1478
|
// future session with no hint that the assignment never landed.
|
|
@@ -1432,7 +1484,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1432
1484
|
`pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
|
|
1433
1485
|
"error",
|
|
1434
1486
|
);
|
|
1487
|
+
return false;
|
|
1435
1488
|
}
|
|
1489
|
+
return true;
|
|
1436
1490
|
}
|
|
1437
1491
|
|
|
1438
1492
|
/** A core's declared verticalization ("Propulsore", "Critico") — the lens it argues from, so a
|
|
@@ -2139,10 +2193,52 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2139
2193
|
|
|
2140
2194
|
// ── /persona ─────────────────────────────────────────────────────────────────
|
|
2141
2195
|
pi.registerCommand("persona", {
|
|
2142
|
-
description: "Switch
|
|
2196
|
+
description: "Switch persona or edit council models: /persona [name|off|list|reload|seed|restore|models [council-persona]]",
|
|
2143
2197
|
handler: async (args, ctx) => {
|
|
2144
2198
|
lastCtx = ctx;
|
|
2145
2199
|
const arg = args.trim();
|
|
2200
|
+
if (arg === "models" || arg.startsWith("models ")) {
|
|
2201
|
+
const caller = controller.activePersona;
|
|
2202
|
+
const scope = modelSelectionScope(ctx);
|
|
2203
|
+
if (!caller || !scope) {
|
|
2204
|
+
ctx.ui.notify("persona models: activate a persona first with /persona <name>.", "warning");
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (!ctx.hasUI) {
|
|
2208
|
+
ctx.ui.notify("persona models: an interactive TUI or RPC UI is required to select council models; no assignments changed.", "warning");
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
const profile = arg.slice("models".length).trim();
|
|
2212
|
+
const resolved = resolveCouncilInvocation(personas, caller, profile ? { persona: profile } : {});
|
|
2213
|
+
if (!resolved.ok) {
|
|
2214
|
+
ctx.ui.notify(`persona models: ${resolved.error}`, "error");
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
try {
|
|
2218
|
+
const { strategy, roster, members, params } = resolved.value;
|
|
2219
|
+
const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
|
|
2220
|
+
const deadline = AbortSignal.timeout(20_000);
|
|
2221
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
|
|
2222
|
+
const result = await ctx.modelRegistry.refresh?.({ signal });
|
|
2223
|
+
if (!scope.isCurrent()) {
|
|
2224
|
+
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
if (signal.aborted || result?.aborted) {
|
|
2228
|
+
ctx.ui.notify("persona models: refresh cancelled or timed out; previous assignments kept.", "warning");
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
if (result?.errors.size) {
|
|
2232
|
+
const providers = compactInlineText([...result.errors.keys()].join(", "), { maxChars: 160 });
|
|
2233
|
+
ctx.ui.notify(`persona models: refresh incomplete for ${providers}; previous assignments kept. Check provider configuration and retry.`, "warning");
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
await editPersonaModels(ctx, runParticipants(ctx, { strategy, members: panel, params }), scope);
|
|
2237
|
+
} catch (err) {
|
|
2238
|
+
ctx.ui.notify(`persona models: refresh failed (${err instanceof Error ? err.message : String(err)}); previous assignments kept.`, "error");
|
|
2239
|
+
}
|
|
2240
|
+
return;
|
|
2241
|
+
}
|
|
2146
2242
|
if (arg === "off" || arg === "none") {
|
|
2147
2243
|
await controller.deactivate();
|
|
2148
2244
|
persist(undefined);
|
|
@@ -30,7 +30,7 @@ export interface PersonaHost {
|
|
|
30
30
|
setThinkingLevel(level: ThinkingLevel): void;
|
|
31
31
|
getModel(): ModelHandle | undefined;
|
|
32
32
|
findModel(ref: string): ModelHandle | undefined;
|
|
33
|
-
setModel(model: ModelHandle): void | Promise<void>;
|
|
33
|
+
setModel(model: ModelHandle): void | boolean | Promise<void | boolean>;
|
|
34
34
|
setStatus(text: string | undefined): void;
|
|
35
35
|
/** Optional: a declared-but-unavailable model/thinking would otherwise fail silently. */
|
|
36
36
|
warn?(message: string): void;
|
|
@@ -108,11 +108,24 @@ export class PersonaController {
|
|
|
108
108
|
if (persona.model) {
|
|
109
109
|
const model = this.host.findModel(persona.model);
|
|
110
110
|
if (model) {
|
|
111
|
-
|
|
111
|
+
const firstOverride = !this.modelOverridden;
|
|
112
|
+
if (firstOverride) {
|
|
112
113
|
this.baselineModel = this.host.getModel();
|
|
113
114
|
this.modelOverridden = true;
|
|
114
115
|
}
|
|
115
|
-
await this.host.setModel(model);
|
|
116
|
+
const applied = await this.host.setModel(model);
|
|
117
|
+
if (applied === false) {
|
|
118
|
+
// Pi's setModel returns false when the provider has no configured auth. Do not
|
|
119
|
+
// leave a phantom override behind: a later persona or deactivation must not
|
|
120
|
+
// restore a baseline for a model that never became active.
|
|
121
|
+
if (firstOverride) {
|
|
122
|
+
this.modelOverridden = false;
|
|
123
|
+
this.baselineModel = undefined;
|
|
124
|
+
}
|
|
125
|
+
this.host.warn?.(
|
|
126
|
+
`persona "${persona.name}" declared model "${persona.model}" but provider authentication is not configured; keeping the current model`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
116
129
|
} else {
|
|
117
130
|
this.host.warn?.(
|
|
118
131
|
`persona "${persona.name}" declared model "${persona.model}" which is not configured; keeping the current model`,
|
|
@@ -4,7 +4,7 @@ export const TELEMETRY_EVENT_NAME = "pi:telemetry" as const;
|
|
|
4
4
|
export const LEGACY_TELEMETRY_EVENT_NAME = "pi-persona:telemetry" as const;
|
|
5
5
|
export const LEGACY_TELEMETRY_VERSION = 1 as const;
|
|
6
6
|
export const TELEMETRY_PRODUCER_ID = "pi-persona" as const;
|
|
7
|
-
export const TELEMETRY_PRODUCER_VERSION = "1.
|
|
7
|
+
export const TELEMETRY_PRODUCER_VERSION = "1.17.0" as const;
|
|
8
8
|
|
|
9
9
|
export type InstanceStatus = "active" | "idle" | "stopped" | "stale" | (string & {});
|
|
10
10
|
export type AgentKind = "subagent" | "delegate" | "council" | "flow" | "phase" | (string & {});
|
|
@@ -54,6 +54,8 @@ export interface MessageDescriptor {
|
|
|
54
54
|
expectsReply: boolean;
|
|
55
55
|
size: number;
|
|
56
56
|
replyTo?: string;
|
|
57
|
+
/** Transport-native identity, distinct from `id`, which remains the stable telemetry-row key. */
|
|
58
|
+
wireId?: string;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export interface PeerDescriptor {
|
|
@@ -145,9 +147,9 @@ const KNOWN_FIELDS: Record<string, readonly string[]> = {
|
|
|
145
147
|
"instance.stopped": ["reason"], "agent.added": ["id", "label", "kind", "status", "parentId", "agent", "persona", "model"],
|
|
146
148
|
"agent.updated": ["id", "patch"], "agent.removed": ["id", "status"], "agent.cleared": [],
|
|
147
149
|
"tool.started": ["callId", "agentId", "name", "status"], "tool.finished": ["callId", "agentId", "name", "status", "durationMs"],
|
|
148
|
-
"message.sent": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
|
|
149
|
-
"message.received": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
|
|
150
|
-
"message.replied": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
|
|
150
|
+
"message.sent": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
|
|
151
|
+
"message.received": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
|
|
152
|
+
"message.replied": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
|
|
151
153
|
"peers.snapshot": ["peers"],
|
|
152
154
|
};
|
|
153
155
|
|
|
@@ -198,7 +200,7 @@ function validKnownPayload(type: string, payload: Record<string, unknown>): bool
|
|
|
198
200
|
if (type === "agent.removed") return stringField(payload, "id") && (payload.status === undefined || ["done", "failed", "stopped"].includes(payload.status as string));
|
|
199
201
|
if (type === "tool.started") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && stringField(payload, "status") && !TERMINAL_TOOL_STATUS.includes(payload.status as string);
|
|
200
202
|
if (type === "tool.finished") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && ["done", "failed"].includes(payload.status as string) && numberField(payload, "durationMs", { min: 0 });
|
|
201
|
-
if (type === "message.sent" || type === "message.received" || type === "message.replied") return stringField(payload, "id") && stringField(payload, "channel") && stringField(payload, "from") && stringField(payload, "to") && stringField(payload, "kind") && stringField(payload, "status") && typeof payload.expectsReply === "boolean" && numberField(payload, "size", { integer: true, min: 0 }) && (payload.replyTo === undefined || stringField(payload, "replyTo"));
|
|
203
|
+
if (type === "message.sent" || type === "message.received" || type === "message.replied") return stringField(payload, "id") && stringField(payload, "channel") && stringField(payload, "from") && stringField(payload, "to") && stringField(payload, "kind") && stringField(payload, "status") && typeof payload.expectsReply === "boolean" && numberField(payload, "size", { integer: true, min: 0 }) && (payload.replyTo === undefined || stringField(payload, "replyTo")) && (payload.wireId === undefined || stringField(payload, "wireId"));
|
|
202
204
|
if (type === "peers.snapshot") return Array.isArray(payload.peers) && payload.peers.every((peer) => { const p = objectPayload(peer); return p !== undefined && stringField(p, "sessionId") && stringField(p, "displayName") && stringField(p, "persona", false) && stringField(p, "model", false) && numberField(p, "contextPercent", { min: 0, max: 100 }) && typeof p.status === "string" && numberField(p, "sent", { integer: true, min: 0 }) && numberField(p, "received", { integer: true, min: 0 }); });
|
|
203
205
|
return true;
|
|
204
206
|
}
|
package/src/tools/delegate.ts
CHANGED
|
@@ -654,7 +654,9 @@ export async function runDelegate(
|
|
|
654
654
|
(steer) => onLegSteerable?.(0, steer),
|
|
655
655
|
);
|
|
656
656
|
const text = r.ok ? r.output || "(no output)" : `Agent "${params.agent}" failed: ${r.error ?? "(no detail)"}`;
|
|
657
|
-
|
|
657
|
+
const terminalView = viewOf(params.agent, label, r);
|
|
658
|
+
onProgress?.([{ ...terminalView }]);
|
|
659
|
+
return { text, results: [r], views: [terminalView], ok: r.ok };
|
|
658
660
|
}
|
|
659
661
|
|
|
660
662
|
return { text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }", results: [], views: [], ok: false };
|
package/src/tools/exocom.ts
CHANGED
|
@@ -36,6 +36,11 @@ export const MAX_BROADCAST_DETAIL_ITEMS = 64;
|
|
|
36
36
|
const MODEL_BROADCAST_ID_SAMPLE = 12;
|
|
37
37
|
const MODEL_BROADCAST_FAILURE_SAMPLE = 8;
|
|
38
38
|
|
|
39
|
+
/** Keep a correlation token only when it is already safe and bounded; never truncate a route token. */
|
|
40
|
+
function isExactBoundedMetadataText(value: unknown, max: number): value is string {
|
|
41
|
+
return typeof value === "string" && value.length > 0 && normalizeMetadataText(value, max, "") === value;
|
|
42
|
+
}
|
|
43
|
+
|
|
39
44
|
const ExocomListParams = Type.Object({
|
|
40
45
|
offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 1_000_000, description: "Zero-based page offset into the live peer list." })),
|
|
41
46
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_MODEL_PEER_ROWS, description: `Maximum peers to show (default ${MAX_MODEL_PEER_ROWS}).` })),
|
|
@@ -68,6 +73,8 @@ type ExocomSendDetails =
|
|
|
68
73
|
queuedCount: number;
|
|
69
74
|
failedCount: number;
|
|
70
75
|
msg_ids: string[];
|
|
76
|
+
/** Bounded transport receipts, retaining the exact route token needed to correlate each peer. */
|
|
77
|
+
sent: Array<{ target: string; msg_id: string }>;
|
|
71
78
|
failed: Array<{ target: string; error: string }>;
|
|
72
79
|
omittedMsgIds: number;
|
|
73
80
|
omittedFailures: number;
|
|
@@ -386,6 +393,11 @@ export function registerExocomTools(
|
|
|
386
393
|
`${normalizeMetadataText(item.target, 80, "peer")}: ${rejectedTargets.has(item.target) ? "rejected by the peer; reason quoted below" : normalizeMetadataText(item.error, 160, "send failed")}`,
|
|
387
394
|
);
|
|
388
395
|
const omittedFailures = failed.length - shownFailures.length;
|
|
396
|
+
const shownReceipts = sent.flatMap((item) =>
|
|
397
|
+
isExactBoundedMetadataText(item.target, 80) && isExactBoundedMetadataText(item.msg_id, 128)
|
|
398
|
+
? [{ target: item.target, msg_id: item.msg_id }]
|
|
399
|
+
: [],
|
|
400
|
+
).slice(0, MAX_BROADCAST_DETAIL_ITEMS);
|
|
389
401
|
const action = omittedIds > 0 || omittedFailures > 0
|
|
390
402
|
? `; ${[
|
|
391
403
|
shownIds.length > 0 ? `msg_ids: ${shownIds.join(", ")}${omittedIds > 0 ? `; +${omittedIds} more msg_ids omitted` : ""}` : "",
|
|
@@ -402,11 +414,12 @@ export function registerExocomTools(
|
|
|
402
414
|
queuedCount: sent.length,
|
|
403
415
|
failedCount: failed.length,
|
|
404
416
|
msg_ids: sent.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((item) => normalizeMetadataText(item.msg_id, 128, "msg")),
|
|
417
|
+
sent: shownReceipts,
|
|
405
418
|
failed: failed.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((failure) => ({
|
|
406
419
|
target: normalizeMetadataText(failure.target, 80, "peer"),
|
|
407
420
|
error: normalizeMetadataText(failure.error, 240, "send failed"),
|
|
408
421
|
})),
|
|
409
|
-
omittedMsgIds: Math.max(0, sent.length -
|
|
422
|
+
omittedMsgIds: Math.max(0, sent.length - shownReceipts.length),
|
|
410
423
|
omittedFailures: Math.max(0, failed.length - MAX_BROADCAST_DETAIL_ITEMS),
|
|
411
424
|
},
|
|
412
425
|
};
|