@aeondave/pi-persona 1.17.3 → 1.17.5
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/AGENTS.md +57 -36
- package/CHANGELOG.md +106 -1
- package/README.md +32 -16
- package/docs/ARCHITECTURE.md +199 -48
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/docs/STRATEGIES.md +6 -0
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +33 -23
- package/src/bus/broker/client.ts +151 -30
- package/src/bus/broker/host.ts +130 -11
- package/src/bus/broker/messages.ts +35 -5
- package/src/bus/contact-transport.ts +8 -0
- package/src/bus/contact.ts +118 -40
- package/src/bus/peers.ts +23 -6
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/companion.ts +64 -0
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +100 -20
- package/src/engine/async.ts +53 -11
- package/src/engine/child.ts +11 -2
- package/src/engine/inproc.ts +98 -47
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/companion.ts +38 -0
- package/src/extension/engine.ts +40 -29
- package/src/extension/hooks.ts +954 -931
- package/src/extension/identity.ts +41 -75
- package/src/extension/memory-owner.ts +54 -0
- package/src/extension/mind-worker.ts +129 -0
- package/src/extension/results.ts +138 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2922 -2849
- package/src/loader.ts +11 -3
- package/src/orchestration/agent-attempt.ts +15 -0
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +125 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +61 -19
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +6 -7
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/activity-rows.ts +34 -0
- package/src/ui/agent-overlay.ts +23 -13
- package/src/ui/agent-tree.ts +67 -6
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
- package/src/ui/strategy-leg.ts +40 -0
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Slimmed wire-frame catalog + validators (spec B6) — comtac's protocol trimmed to the
|
|
3
3
|
* frames pi-persona actually needs: register/send/cancel/reply/list/bye (client→host) and
|
|
4
|
-
* registered/deliver/steer/peers/replied/error (host→client). Mirrors comtac's
|
|
4
|
+
* registered/sent/deliver/steer/peers/replied/error (host→client). Mirrors comtac's
|
|
5
5
|
* `messages.ts` validation STYLE (structural per-field checks, one explicit assert per
|
|
6
6
|
* case) but drops presence/sessions/attachments/broadcasts entirely (YAGNI here).
|
|
7
7
|
*
|
|
8
|
+
* Authenticated result_read/result_read_reply/result_read_cancel frames extend the same
|
|
9
|
+
* connection with a closed bounded page contract. No archive summaries or client grants.
|
|
8
10
|
* Pure module: no Pi imports, unit-tested.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import type { MsgKind } from "../inproc.ts";
|
|
14
|
+
import { closedRecord, isReadReply, isReadRequest, type ReadReply, type ResultReadRequest } from "../result-reader.ts";
|
|
15
|
+
|
|
16
|
+
export function isReaderToken(value: unknown): value is string {
|
|
17
|
+
return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isRequestId(value: unknown): value is string {
|
|
21
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
|
|
22
|
+
}
|
|
12
23
|
|
|
13
24
|
export type Frame =
|
|
14
|
-
| { t: "register"; handle: string; label?: string; group?: string; peers?: boolean }
|
|
15
|
-
| { t: "registered"; handle: string }
|
|
25
|
+
| { t: "register"; handle: string; label?: string; group?: string; peers?: boolean; readerToken?: string }
|
|
26
|
+
| { t: "registered"; handle: string; readerToken?: string }
|
|
27
|
+
| ({ t: "result_read"; reqId: string } & ResultReadRequest)
|
|
28
|
+
| { t: "result_read_reply"; reqId: string; result: ReadReply }
|
|
29
|
+
| { t: "result_read_cancel"; reqId: string }
|
|
16
30
|
| { t: "send"; to: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
|
|
31
|
+
| { t: "sent"; msgId: string; delivered: boolean }
|
|
17
32
|
| { t: "cancel"; msgId: string }
|
|
18
33
|
| { t: "deliver"; from: string; fromLabel?: string; kind: MsgKind; text: string; msgId: string; expectsReply: boolean }
|
|
19
34
|
| { t: "reply"; askId: string; text: string }
|
|
@@ -39,14 +54,27 @@ function isPeerEntry(value: unknown): value is { handle: string; label: string }
|
|
|
39
54
|
export function isFrame(value: unknown): value is Frame {
|
|
40
55
|
if (!isRecord(value)) return false;
|
|
41
56
|
switch (value.t) {
|
|
57
|
+
case "result_read": {
|
|
58
|
+
if (!closedRecord(value, ["t", "reqId", "ref", "offset", "limit"]) || !isRequestId(value.reqId)) return false;
|
|
59
|
+
const { t: _t, reqId: _id, ...request } = value;
|
|
60
|
+
return isReadRequest(request);
|
|
61
|
+
}
|
|
62
|
+
case "result_read_reply":
|
|
63
|
+
return closedRecord(value, ["t", "reqId", "result"]) && isRequestId(value.reqId) && isReadReply(value.result);
|
|
64
|
+
case "result_read_cancel":
|
|
65
|
+
return closedRecord(value, ["t", "reqId"]) && isRequestId(value.reqId);
|
|
42
66
|
case "register": {
|
|
43
|
-
if (
|
|
67
|
+
if (!closedRecord(value, ["t", "handle", "label", "group", "peers", "readerToken"])) return false;
|
|
68
|
+
if ("readerToken" in value && !isReaderToken(value.readerToken)) return false;
|
|
69
|
+
if (typeof value.handle !== "string" || value.handle.length === 0 || value.handle.length > 256) return false;
|
|
44
70
|
if (value.label !== undefined && typeof value.label !== "string") return false;
|
|
45
71
|
if (value.group !== undefined && typeof value.group !== "string") return false;
|
|
46
72
|
return value.peers === undefined || typeof value.peers === "boolean";
|
|
47
73
|
}
|
|
48
74
|
case "registered":
|
|
49
|
-
return typeof value.handle === "string"
|
|
75
|
+
return closedRecord(value, ["t", "handle", "readerToken"]) && typeof value.handle === "string"
|
|
76
|
+
&& value.handle.length > 0 && value.handle.length <= 256
|
|
77
|
+
&& (!("readerToken" in value) || isReaderToken(value.readerToken));
|
|
50
78
|
case "send":
|
|
51
79
|
return (
|
|
52
80
|
typeof value.to === "string" &&
|
|
@@ -57,6 +85,8 @@ export function isFrame(value: unknown): value is Frame {
|
|
|
57
85
|
);
|
|
58
86
|
case "cancel":
|
|
59
87
|
return typeof value.msgId === "string";
|
|
88
|
+
case "sent":
|
|
89
|
+
return closedRecord(value, ["t", "msgId", "delivered"]) && typeof value.msgId === "string" && typeof value.delivered === "boolean";
|
|
60
90
|
case "deliver": {
|
|
61
91
|
if (typeof value.from !== "string") return false;
|
|
62
92
|
if (value.fromLabel !== undefined && typeof value.fromLabel !== "string") return false;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { InProcessBus, MsgKind } from "./inproc.ts";
|
|
2
|
+
|
|
3
|
+
/** Child contact tools need delivery admission, not a concrete mailbox implementation.
|
|
4
|
+
* A remote send awaits only the host's bounded receipt, never the recipient's answer. */
|
|
5
|
+
export interface ContactTransport {
|
|
6
|
+
send(from: string, to: string, text: string, kind?: MsgKind, signal?: AbortSignal): boolean | Promise<boolean>;
|
|
7
|
+
ask: InProcessBus["ask"];
|
|
8
|
+
}
|
package/src/bus/contact.ts
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
* `customTool` by the engine, bound to the shared {@link InProcessBus} + the child's
|
|
6
6
|
* handle.
|
|
7
7
|
*
|
|
8
|
-
* - `
|
|
9
|
-
*
|
|
10
|
-
* - `need_decision` / `interview_request` (blocking): `bus.ask` — the child waits for
|
|
8
|
+
* - `progress` (one-way): post and return immediately.
|
|
9
|
+
* - `decision` / `interview` (blocking): `bus.ask` — the child waits for
|
|
11
10
|
* the supervisor's reply. This only makes sense in **async** runs where the supervisor
|
|
12
11
|
* is free to answer; the tool aborts cleanly on the run's signal so it never hangs a
|
|
13
12
|
* sync run with no one listening.
|
|
@@ -18,27 +17,69 @@
|
|
|
18
17
|
import { type Static, Type } from "typebox";
|
|
19
18
|
|
|
20
19
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
21
21
|
|
|
22
|
-
import
|
|
22
|
+
import { compactInlineText } from "../ui/presentation.ts";
|
|
23
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
24
|
+
import type { MsgKind } from "./inproc.ts";
|
|
25
|
+
import type { ContactTransport } from "./contact-transport.ts";
|
|
23
26
|
|
|
24
27
|
const ContactParams = Type.Object({
|
|
25
28
|
kind: Type.Union([Type.Literal("progress"), Type.Literal("decision"), Type.Literal("interview")], {
|
|
26
29
|
description:
|
|
27
|
-
"progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question.
|
|
30
|
+
"progress = one-way status update (don't wait); decision = ask the supervisor to choose; interview = ask a clarifying question. Async decision/interview requests wait for a reply; synchronous requests post one-way with no possible answer, so the request remains unresolved.",
|
|
28
31
|
}),
|
|
29
32
|
message: Type.String({ description: "What to tell or ask the supervisor — specific and self-contained." }),
|
|
30
33
|
});
|
|
31
34
|
|
|
32
|
-
type
|
|
35
|
+
type ContactOutcome = "reported" | "answered" | "unavailable" | "timed-out" | "cancelled" | "failed";
|
|
36
|
+
|
|
37
|
+
type ContactDetails = {
|
|
38
|
+
kind: MsgKind;
|
|
39
|
+
/** Delivery evidence only, never evidence of a reply or approval. False means delivery was not confirmed. */
|
|
40
|
+
delivered: boolean;
|
|
41
|
+
outcome: ContactOutcome;
|
|
42
|
+
};
|
|
33
43
|
|
|
34
44
|
type AskFailure = "aborted" | "timeout" | "inbox-full" | "unavailable" | "generic";
|
|
35
45
|
|
|
46
|
+
const UNRESOLVED_NEXT_STEP = "Continue only with independent work already authorized; otherwise return a blocked/partial result.";
|
|
47
|
+
|
|
48
|
+
function unresolvedGuidance(kind: "decision" | "interview"): string {
|
|
49
|
+
return `The ${kind} remains unresolved. ${UNRESOLVED_NEXT_STEP}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function outcomeForAskFailure(failure: AskFailure): ContactOutcome {
|
|
53
|
+
switch (failure) {
|
|
54
|
+
case "aborted": return "cancelled";
|
|
55
|
+
case "timeout": return "timed-out";
|
|
56
|
+
case "inbox-full":
|
|
57
|
+
case "unavailable": return "unavailable";
|
|
58
|
+
case "generic": return "failed";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function askFailureText(failure: AskFailure): string {
|
|
63
|
+
switch (failure) {
|
|
64
|
+
case "aborted": return "The request was cancelled before the supervisor replied.";
|
|
65
|
+
case "timeout": return "The supervisor did not reply in time.";
|
|
66
|
+
case "inbox-full": return "The supervisor inbox is full; this request was not delivered. Retry later or consolidate the question.";
|
|
67
|
+
case "unavailable": return "The supervisor is unavailable; delivery could not be confirmed.";
|
|
68
|
+
case "generic": return "The supervisor request failed; delivery could not be confirmed.";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
36
72
|
function classifyAskFailure(error: unknown, signal: AbortSignal | undefined): AskFailure {
|
|
37
73
|
if (signal?.aborted) return "aborted";
|
|
38
74
|
const message = error instanceof Error ? error.message : "";
|
|
39
75
|
if (/^ask timeout(?:\s|$)/i.test(message)) return "timeout";
|
|
40
76
|
if (/^inbox full(?:\s|:|$)/i.test(message)) return "inbox-full";
|
|
41
|
-
if (
|
|
77
|
+
if (
|
|
78
|
+
/^ask unregistered(?:\s|$)/i.test(message) ||
|
|
79
|
+
/^unknown peer(?:\s|:|$)/i.test(message) ||
|
|
80
|
+
/^bridge: not connected(?:\s|$)/i.test(message) ||
|
|
81
|
+
/^broker (?:connection|client) closed(?:\s|$)/i.test(message)
|
|
82
|
+
) return "unavailable";
|
|
42
83
|
return "generic";
|
|
43
84
|
}
|
|
44
85
|
|
|
@@ -52,9 +93,9 @@ function result(t: string, details: ContactDetails): { content: [{ type: "text";
|
|
|
52
93
|
*
|
|
53
94
|
* `allowBlocking` (async runs only): when false, a `decision`/`interview` does NOT block —
|
|
54
95
|
* the supervisor is busy (a sync run holds the turn), so the question is posted one-way and
|
|
55
|
-
*
|
|
96
|
+
* returns unresolved. This prevents a sync delegate/council from deadlocking on a child ask. */
|
|
56
97
|
export function makeContactSupervisorTool(
|
|
57
|
-
bus:
|
|
98
|
+
bus: ContactTransport,
|
|
58
99
|
fromHandle: string,
|
|
59
100
|
supervisorHandle: string,
|
|
60
101
|
opts: { allowBlocking?: boolean } = {},
|
|
@@ -68,32 +109,51 @@ export function makeContactSupervisorTool(
|
|
|
68
109
|
"Use `progress` to report a milestone (one-way, don't wait).",
|
|
69
110
|
"Use `decision` when you hit a choice only the supervisor should make, or `interview` to ask a clarifying question.",
|
|
70
111
|
allowBlocking
|
|
71
|
-
? "This async run waits for the supervisor's reply before continuing."
|
|
72
|
-
: "This synchronous run cannot block: decision/interview are posted one-way and return immediately;
|
|
112
|
+
? "This async run waits for the supervisor's reply before continuing. If there is no reply, the request remains unresolved: continue only with independent work already authorized, otherwise return a blocked/partial result."
|
|
113
|
+
: "This synchronous run cannot block: decision/interview are posted one-way and return immediately; no answer is possible in this path, so the request remains unresolved. Continue only with independent work already authorized, otherwise return a blocked/partial result.",
|
|
73
114
|
`Messages longer than ${MAX_CONTACT_MESSAGE_CHARS} characters are shortened before delivery; use smaller messages to preserve full detail.`,
|
|
74
115
|
].join(" "),
|
|
75
116
|
promptSnippet: "contact_supervisor — report progress or ask the supervisor a question",
|
|
76
117
|
parameters: ContactParams,
|
|
77
118
|
async execute(_toolCallId, params: Static<typeof ContactParams>, signal, _onUpdate, _ctx) {
|
|
78
119
|
const kind = params.kind as MsgKind;
|
|
120
|
+
if (signal?.aborted) {
|
|
121
|
+
const text = kind === "progress"
|
|
122
|
+
? "The progress update was cancelled before delivery."
|
|
123
|
+
: `The request was cancelled before the supervisor was contacted. ${unresolvedGuidance(kind)}`;
|
|
124
|
+
return result(text, { kind, delivered: false, outcome: "cancelled" });
|
|
125
|
+
}
|
|
79
126
|
const message = boundContactMessage(params.message);
|
|
80
127
|
if (kind === "progress") {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
try {
|
|
129
|
+
const delivered = await bus.send(fromHandle, supervisorHandle, message, "progress", signal);
|
|
130
|
+
if (signal?.aborted) return result("The progress update was cancelled; delivery could not be confirmed.", { kind, delivered: false, outcome: "cancelled" });
|
|
131
|
+
return result(
|
|
132
|
+
delivered ? "Progress reported to the supervisor." : "Progress delivery could not be confirmed. The supervisor may already have received the update.",
|
|
133
|
+
{ kind, delivered, outcome: delivered ? "reported" : "unavailable" },
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
return result("Progress delivery could not be confirmed. The supervisor may already have received the update.", { kind, delivered: false, outcome: "failed" });
|
|
137
|
+
}
|
|
86
138
|
}
|
|
87
139
|
|
|
88
140
|
// In a sync run the supervisor is blocked holding the turn → it cannot answer. Post the
|
|
89
|
-
// question one-way
|
|
141
|
+
// question one-way, but return unresolved; the send is not authorization to proceed on it.
|
|
90
142
|
if (!allowBlocking) {
|
|
91
|
-
|
|
143
|
+
let delivered: boolean;
|
|
144
|
+
try {
|
|
145
|
+
delivered = await bus.send(fromHandle, supervisorHandle, message, kind, signal);
|
|
146
|
+
} catch {
|
|
147
|
+
return result(
|
|
148
|
+
`Delivery of the question could not be confirmed. The supervisor may already have received it; no answer is possible in this path. ${unresolvedGuidance(kind)}`,
|
|
149
|
+
{ kind, delivered: false, outcome: "failed" },
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
if (signal?.aborted) return result(`The request was cancelled. ${unresolvedGuidance(kind)}`, { kind, delivered: false, outcome: "cancelled" });
|
|
153
|
+
const delivery = delivered ? "question sent one-way" : "question delivery could not be confirmed";
|
|
92
154
|
return result(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
: "(no supervisor reachable — proceed using your best judgement)",
|
|
96
|
-
{ kind, delivered },
|
|
155
|
+
`The supervisor is busy right now; ${delivery}, and no answer is possible in this path. ${unresolvedGuidance(kind)}`,
|
|
156
|
+
{ kind, delivered, outcome: "unavailable" },
|
|
97
157
|
);
|
|
98
158
|
}
|
|
99
159
|
|
|
@@ -103,30 +163,48 @@ export function makeContactSupervisorTool(
|
|
|
103
163
|
let answer: Promise<string>;
|
|
104
164
|
try {
|
|
105
165
|
answer = bus.ask(fromHandle, supervisorHandle, message, signal ? { kind, signal } : { kind });
|
|
106
|
-
} catch {
|
|
107
|
-
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const failure = classifyAskFailure(error, signal);
|
|
168
|
+
return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
|
|
169
|
+
kind,
|
|
170
|
+
delivered: false,
|
|
171
|
+
outcome: outcomeForAskFailure(failure),
|
|
172
|
+
});
|
|
108
173
|
}
|
|
109
174
|
try {
|
|
110
175
|
const reply = await answer;
|
|
111
|
-
|
|
176
|
+
// A response that settles concurrently with cancellation is stale for this call:
|
|
177
|
+
// never report it as an answer once the caller's signal has aborted.
|
|
178
|
+
if (signal?.aborted) {
|
|
179
|
+
return result(`${askFailureText("aborted")} ${unresolvedGuidance(kind)}`, {
|
|
180
|
+
kind,
|
|
181
|
+
delivered: false,
|
|
182
|
+
outcome: "cancelled",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return result(`Supervisor: ${reply}`, { kind, delivered: true, outcome: "answered" });
|
|
112
186
|
} catch (error) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
case "inbox-full":
|
|
120
|
-
return "(the supervisor inbox is full — this request was not delivered; retry later or consolidate the question)";
|
|
121
|
-
case "unavailable":
|
|
122
|
-
return "(the supervisor is no longer available — this request was not delivered; proceed using your best judgement)";
|
|
123
|
-
default:
|
|
124
|
-
return "(the supervisor request could not be completed — proceed using your best judgement)";
|
|
125
|
-
}
|
|
126
|
-
})();
|
|
127
|
-
return result(msg, { kind, delivered: false });
|
|
187
|
+
const failure = classifyAskFailure(error, signal);
|
|
188
|
+
return result(`${askFailureText(failure)} ${unresolvedGuidance(kind)}`, {
|
|
189
|
+
kind,
|
|
190
|
+
delivered: false,
|
|
191
|
+
outcome: outcomeForAskFailure(failure),
|
|
192
|
+
});
|
|
128
193
|
}
|
|
129
194
|
},
|
|
195
|
+
// Compact purpose line only: the message is the child's own prose, and echoing it in the
|
|
196
|
+
// call card would overflow the transcript the way the host fallback's raw-args echo does.
|
|
197
|
+
renderCall(args, theme) {
|
|
198
|
+
const kind = compactInlineText(args.kind ?? "", { maxChars: 12 });
|
|
199
|
+
return new Text(theme.fg("toolTitle", theme.bold(kind ? `contact ${kind}` : "contact")), 0, 0);
|
|
200
|
+
},
|
|
201
|
+
// One shared literal receipt. An answered ask is bounded collapsed and expands to the full
|
|
202
|
+
// reply; progress receipts are naturally one compact line. Unresolved outcomes
|
|
203
|
+
// (unavailable/timed-out/cancelled/failed) are SUCCESSFUL tool results carrying an
|
|
204
|
+
// unresolved outcome — the error heading appears only when the host context says isError.
|
|
205
|
+
renderResult(result, { expanded }, theme, context) {
|
|
206
|
+
return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
207
|
+
},
|
|
130
208
|
});
|
|
131
209
|
}
|
|
132
210
|
/** Maximum body accepted by contact_supervisor's message action. Full reports should use the
|
package/src/bus/peers.ts
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
import { type Static, Type } from "typebox";
|
|
21
21
|
|
|
22
22
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
23
24
|
|
|
24
|
-
import
|
|
25
|
+
import { compactInlineText } from "../ui/presentation.ts";
|
|
26
|
+
import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
|
|
27
|
+
import type { ContactTransport } from "./contact-transport.ts";
|
|
25
28
|
|
|
26
29
|
/** Anti ping-pong budget: after this many sends the tool tells the member to finalize. */
|
|
27
30
|
export const MAX_PEER_SENDS = 20;
|
|
@@ -56,7 +59,7 @@ function result(t: string, details: PeerDetails): { content: [{ type: "text"; te
|
|
|
56
59
|
|
|
57
60
|
/** Build the child's `contact_peer` tool. Same `defineTool` variance shim as
|
|
58
61
|
* `makeContactSupervisorTool`, so it slots into `customTools: ToolDefinition[]`. */
|
|
59
|
-
export function makeContactPeerTool(bus:
|
|
62
|
+
export function makeContactPeerTool(bus: Pick<ContactTransport, "send">, selfHandle: string, deps: ContactPeerDeps) {
|
|
60
63
|
const maxSends = deps.maxSends ?? MAX_PEER_SENDS;
|
|
61
64
|
let sent = 0;
|
|
62
65
|
return defineTool({
|
|
@@ -71,7 +74,7 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
|
|
|
71
74
|
].join(" "),
|
|
72
75
|
promptSnippet: "contact_peer — list your run's sibling agents or send one a one-way note",
|
|
73
76
|
parameters: PeerParams,
|
|
74
|
-
async execute(_toolCallId, params: Static<typeof PeerParams>,
|
|
77
|
+
async execute(_toolCallId, params: Static<typeof PeerParams>, signal, _onUpdate, _ctx) {
|
|
75
78
|
if (params.action === "list") {
|
|
76
79
|
const peers = await deps.listPeers();
|
|
77
80
|
return result(
|
|
@@ -84,6 +87,7 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
|
|
|
84
87
|
if (!params.to || params.message === undefined) {
|
|
85
88
|
return result("contact_peer send needs { to, message } — use `list` for peer handles.", { action: "send", delivered: false });
|
|
86
89
|
}
|
|
90
|
+
if (signal?.aborted) return result("Peer message cancelled before delivery.", { action: "send", delivered: false });
|
|
87
91
|
if (params.message.length > MAX_PEER_MESSAGE_CHARS) {
|
|
88
92
|
return result(
|
|
89
93
|
`contact_peer message exceeds the ${MAX_PEER_MESSAGE_CHARS}-character limit; send a smaller note.`,
|
|
@@ -99,16 +103,29 @@ export function makeContactPeerTool(bus: InProcessBus, selfHandle: string, deps:
|
|
|
99
103
|
sent += 1;
|
|
100
104
|
// Enforce the run-isolation boundary (previously only convention): a handle that is
|
|
101
105
|
// not one of THIS run's peers (e.g. "supervisor", or another run's child) never gets
|
|
102
|
-
// bus.send called at all — same
|
|
106
|
+
// bus.send called at all — same unavailable wording as an actually-finished peer, so the
|
|
103
107
|
// tool result gives no signal either way about what exists outside this run's scope.
|
|
104
108
|
const inScope = (await deps.listPeers()).some((p) => p.handle === params.to);
|
|
105
|
-
const
|
|
109
|
+
const accepted = inScope && !signal?.aborted && await bus.send(selfHandle, params.to, params.message, "progress", signal);
|
|
110
|
+
const delivered = accepted && !signal?.aborted;
|
|
106
111
|
return result(
|
|
107
112
|
delivered
|
|
108
113
|
? `Sent to ${params.to}.`
|
|
109
|
-
: `
|
|
114
|
+
: `Delivery to peer "${params.to}" could not be confirmed — it may be unavailable or its inbox full. Proceed with your own judgement.`,
|
|
110
115
|
{ action: "send", delivered },
|
|
111
116
|
);
|
|
112
117
|
},
|
|
118
|
+
// Compact purpose line only — same rule as contact_supervisor: no note body in the call
|
|
119
|
+
// card (the host fallback would echo raw args).
|
|
120
|
+
renderCall(args, theme) {
|
|
121
|
+
const action = compactInlineText(args.action ?? "", { maxChars: 12 });
|
|
122
|
+
return new Text(theme.fg("toolTitle", theme.bold(action ? `peer ${action}` : "peer")), 0, 0);
|
|
123
|
+
},
|
|
124
|
+
// One shared literal receipt: a send receipt is one compact line; a long peer list stays
|
|
125
|
+
// bounded collapsed (expansion hint) and shows every peer expanded. The error heading comes
|
|
126
|
+
// only from the host's isError flag, never from the semantic content.
|
|
127
|
+
renderResult(result, { expanded }, theme, context) {
|
|
128
|
+
return renderLiteralToolResult(result, { expanded }, theme, context);
|
|
129
|
+
},
|
|
113
130
|
});
|
|
114
131
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Narrow result-read boundary. No archive ownership, discovery, paths or session identity.
|
|
2
|
+
* The parent supplies a reader that checks CURRENT capabilities/owner and its explicit ref
|
|
3
|
+
* grant on every call. The broker owns attachment, cancellation and wire validation only. */
|
|
4
|
+
import { isResultRef } from "../results/record.ts";
|
|
5
|
+
import { RESULT_READ_DEFAULT, RESULT_READ_MAX, type ResultReadRequest, type ResultProvenance,
|
|
6
|
+
type TerminalStatus } from "../results/types.ts";
|
|
7
|
+
|
|
8
|
+
export type { ResultReadRequest } from "../results/types.ts";
|
|
9
|
+
export const RESULT_READ_TIMEOUT_MS = 10_000;
|
|
10
|
+
export const RESULT_READ_MAX_PENDING = 8;
|
|
11
|
+
export const RESULT_READ_MAX_FRAME_BYTES = 128 * 1024;
|
|
12
|
+
/** Bound replay tracking without ever evicting an id and accepting its replay. Exhaustion
|
|
13
|
+
* ends the reader connection; it does not reconnect or retry. */
|
|
14
|
+
export const RESULT_READ_MAX_REQUESTS = 4096;
|
|
15
|
+
|
|
16
|
+
const ERROR_MESSAGES = {
|
|
17
|
+
invalid: "Invalid result read request.",
|
|
18
|
+
denied: "Result read is not authorized.",
|
|
19
|
+
revoked: "Result reader was revoked.",
|
|
20
|
+
unavailable: "Result reader is unavailable.",
|
|
21
|
+
cancelled: "Result read was cancelled.",
|
|
22
|
+
timeout: "Result read deadline exceeded.",
|
|
23
|
+
busy: "Too many pending result reads.",
|
|
24
|
+
protocol: "Invalid result reader protocol.",
|
|
25
|
+
} as const;
|
|
26
|
+
export type ReadErrorCode = keyof typeof ERROR_MESSAGES;
|
|
27
|
+
export type ReadReply = {
|
|
28
|
+
ok: true; ref: string; body: string; offset: number; nextOffset?: number;
|
|
29
|
+
storedChars: number; originalChars: number; truncated: boolean; endOfStoredBody: boolean;
|
|
30
|
+
status: TerminalStatus; provenance: ResultProvenance;
|
|
31
|
+
} | { ok: false; code: ReadErrorCode; message: string };
|
|
32
|
+
|
|
33
|
+
export interface ScopedReader {
|
|
34
|
+
read(request: ResultReadRequest, signal?: AbortSignal): Promise<ReadReply>;
|
|
35
|
+
revoke(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PreparedResultReader {
|
|
39
|
+
/** Private spawn environment only. Never put in tasks, receipts or diagnostics. */
|
|
40
|
+
token: string;
|
|
41
|
+
revoke(): void;
|
|
42
|
+
/** Historical attachment evidence, not a promise of continued connectivity. */
|
|
43
|
+
wasBound(): boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readFailure(code: ReadErrorCode): ReadReply & { ok: false } {
|
|
47
|
+
return { ok: false, code, message: ERROR_MESSAGES[code] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Reject hidden/accessor fields too: this validator is also used on parent callback data. */
|
|
51
|
+
export function closedRecord(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
|
52
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
53
|
+
const proto: unknown = Object.getPrototypeOf(value);
|
|
54
|
+
return (proto === Object.prototype || proto === null) && Reflect.ownKeys(value).every((key) => {
|
|
55
|
+
const d = Object.getOwnPropertyDescriptor(value, key);
|
|
56
|
+
return typeof key === "string" && keys.includes(key) && d?.enumerable === true && "value" in d;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const counter = (n: unknown): n is number => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
|
|
60
|
+
|
|
61
|
+
export function isReadRequest(value: unknown): value is ResultReadRequest {
|
|
62
|
+
return closedRecord(value, ["ref", "offset", "limit"]) && isResultRef(value.ref)
|
|
63
|
+
&& (!("offset" in value) || counter(value.offset))
|
|
64
|
+
&& (!("limit" in value) || (counter(value.limit) && value.limit >= 2 && value.limit <= RESULT_READ_MAX));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isReadReply(value: unknown): value is ReadReply {
|
|
68
|
+
if (!closedRecord(value, ["ok", "ref", "body", "offset", "nextOffset", "storedChars", "originalChars",
|
|
69
|
+
"truncated", "endOfStoredBody", "status", "provenance", "code", "message"])) return false;
|
|
70
|
+
if (value.ok === false) return closedRecord(value, ["ok", "code", "message"])
|
|
71
|
+
&& typeof value.code === "string" && Object.hasOwn(ERROR_MESSAGES, value.code)
|
|
72
|
+
&& value.message === ERROR_MESSAGES[value.code as ReadErrorCode];
|
|
73
|
+
if (value.ok !== true || "code" in value || "message" in value || !isResultRef(value.ref)
|
|
74
|
+
|| typeof value.body !== "string" || value.body.length > RESULT_READ_MAX || !counter(value.offset)
|
|
75
|
+
|| !counter(value.storedChars) || !counter(value.originalChars) || value.originalChars < value.storedChars
|
|
76
|
+
|| value.truncated !== (value.originalChars > value.storedChars)
|
|
77
|
+
|| (value.status !== "done" && value.status !== "failed" && value.status !== "stopped")
|
|
78
|
+
|| (value.provenance !== "result-output" && value.provenance !== "partial-output" && value.provenance !== "error")) return false;
|
|
79
|
+
const end = value.offset + value.body.length;
|
|
80
|
+
if (!Number.isSafeInteger(end) || end > value.storedChars || value.endOfStoredBody !== (end === value.storedChars)) return false;
|
|
81
|
+
return value.endOfStoredBody ? !("nextOffset" in value) : value.body.length > 0 && value.nextOffset === end;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Page offsets are UTF-16; the archive, not transport, owns surrogate-boundary slicing. */
|
|
85
|
+
export function isReadReplyFor(value: unknown, request: ResultReadRequest): value is ReadReply {
|
|
86
|
+
return isReadReply(value) && (!value.ok || (value.ref === request.ref && value.offset === (request.offset ?? 0)
|
|
87
|
+
&& value.body.length <= (request.limit ?? RESULT_READ_DEFAULT)));
|
|
88
|
+
}
|
package/src/core/brief.ts
CHANGED
|
@@ -265,7 +265,11 @@ export interface ExocomBriefTools {
|
|
|
265
265
|
wait: boolean;
|
|
266
266
|
release: boolean;
|
|
267
267
|
progress: boolean;
|
|
268
|
-
status: boolean;
|
|
268
|
+
status: boolean;
|
|
269
|
+
task?: boolean;
|
|
270
|
+
offer?: boolean;
|
|
271
|
+
publish?: boolean;
|
|
272
|
+
profile?: boolean;
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
/** The two runtime facts the peer brief may not assume, because both are false in reachable
|
|
@@ -391,7 +395,11 @@ export function buildExocomBrief(peers: ExocomPeerBrief[], input: ExocomBriefInp
|
|
|
391
395
|
: input.joined === true
|
|
392
396
|
? `External-workspace ledger: repository-relative claims belong to the joined workspace, not your home files, so this instance cannot claim its home paths.${ask}${close}${input.tools.progress ? " Progress notes remain available." : ""}${input.tools.release ? " Release of your outbound asks remains available." : ""}${status}${postcard}${evidence}`
|
|
393
397
|
: `Repository claims are unavailable to this persona in this workspace; do not attempt repository claims.${ask}${close}${outboundLifecycle}${status}${postcard}${evidence}`;
|
|
394
|
-
lines.push(ledger);
|
|
398
|
+
lines.push(ledger);
|
|
399
|
+
if (input.tools.task) lines.push("Shared objectives between independent colleagues: exocom_task({ action: \"list\" }) discovers bounded tasks. Create a clearly scoped task, then let one peer voluntarily take it; pickup reserves its declared write set atomically. Finish with an explicit done/abandoned outcome. Stale tasks are not permission to take over. Each peer remains responsible to its own user; task ownership grants no supervisor authority.");
|
|
400
|
+
if (input.tools.offer) lines.push("exocom_offer sends an optional invitation with an expiry, never an obligation to answer or permission to act. Use it for voluntary collaboration; reserve exocom_ask for a blocking coordination question.");
|
|
401
|
+
if (input.tools.publish) lines.push("exocom_publish shares an explicit bounded evidence excerpt and lets peers retrieve it by message id. Verify attributed evidence; a result archive reference never gives another session access to your archive.");
|
|
402
|
+
if (input.tools.profile) lines.push("exocom_profile advertises self-reported capability tags and availability for discovery. These are not permissions, verified skills or a guarantee of availability; unknown stays unknown.");
|
|
395
403
|
// The line above bounds WHETHER to open a thread; this one bounds how long it stays open. The
|
|
396
404
|
// stop condition is DRIFT, never a round count: back-and-forth is often how a hard point gets
|
|
397
405
|
// settled — what wastes tokens is a round that stopped serving the request, which a counter
|
|
@@ -406,8 +414,10 @@ export function buildExocomBrief(peers: ExocomPeerBrief[], input: ExocomBriefInp
|
|
|
406
414
|
// "ask your human" has no addressee headless, where the silence is the answer, not permission.
|
|
407
415
|
// The hand-off is the other half of the partition the line above opens: peer ⇒ judgement you
|
|
408
416
|
// cannot write down, sub-agent ⇒ work you can.
|
|
409
|
-
const handoff = input.canDelegate
|
|
410
|
-
?
|
|
417
|
+
const handoff = input.canDelegate
|
|
418
|
+
? input.tools.task
|
|
419
|
+
? " Use a sub-agent for supervised work you own; use voluntary shared tasks when independent colleagues agree to divide a common objective."
|
|
420
|
+
: " Work you can specify (measure X, test Y) goes to a sub-agent, which reports back instead of conversing."
|
|
411
421
|
: "";
|
|
412
422
|
// The stop ACTION must be performable on an inbound-driven turn too: "decide it yourself" has no
|
|
413
423
|
// object you own when the work is the peer's question, and escalating someone else's question
|
package/src/core/capabilities.ts
CHANGED
|
@@ -80,7 +80,7 @@ export function canFanOut(caps: EffectiveCapabilities, delegateTool = DEFAULT_DE
|
|
|
80
80
|
return caps.tools.has(delegateTool);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export const EXOCOM_TOOL_NAMES = ["exocom_list", "exocom_send", "exocom_name", "exocom_claim", "exocom_ask", "exocom_answer", "exocom_decline", "exocom_wait", "exocom_release", "exocom_progress", "exocom_status"] as const;
|
|
83
|
+
export const EXOCOM_TOOL_NAMES = ["exocom_list", "exocom_send", "exocom_name", "exocom_claim", "exocom_ask", "exocom_answer", "exocom_decline", "exocom_wait", "exocom_release", "exocom_progress", "exocom_status", "exocom_task", "exocom_offer", "exocom_publish", "exocom_profile"] as const;
|
|
84
84
|
const EXOCOM_TOOLS: ReadonlySet<string> = new Set(EXOCOM_TOOL_NAMES);
|
|
85
85
|
|
|
86
86
|
export function canCallTool(caps: EffectiveCapabilities, toolName: string): boolean {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Public local companion contracts. These describe loaded capabilities and UI activity, not authority. */
|
|
2
|
+
export const COMPANION_EVENT = "pi-persona:companion:v1";
|
|
3
|
+
export const ACTIVITY_EVENT = "pi-persona:activity:v1";
|
|
4
|
+
export const MEMORY_OWNER_EVENT = "pi-persona:memory-owner:v1";
|
|
5
|
+
export const MEMORY_OWNER_CHANGED_EVENT = "pi-persona:memory-owner-changed:v1";
|
|
6
|
+
export const MEMORY_PERSONA_ENV = "PI_PERSONA_MEMORY_PERSONA";
|
|
7
|
+
|
|
8
|
+
export interface CompanionMemoryOwner {
|
|
9
|
+
version: 1;
|
|
10
|
+
provider: "pi-persona";
|
|
11
|
+
sessionId: string;
|
|
12
|
+
ready: boolean;
|
|
13
|
+
persona: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Spawn-local inheritance only. Absent/invalid is unavailable, never a global-settings fallback. */
|
|
17
|
+
export function inheritedMemoryPersona(raw: string | undefined): { persona: string | null } | undefined {
|
|
18
|
+
if (raw === undefined || raw.length > 8_192) return undefined;
|
|
19
|
+
try {
|
|
20
|
+
const value = JSON.parse(raw) as Record<string, unknown> | null;
|
|
21
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.version !== 1 || Object.keys(value).some((key) => key !== "version" && key !== "persona")) return undefined;
|
|
22
|
+
if (value.persona !== null && (typeof value.persona !== "string" || !value.persona.trim() || value.persona.length > 1_024 || /[\u0000-\u001f\u007f]/u.test(value.persona))) return undefined;
|
|
23
|
+
return { persona: value.persona as string | null };
|
|
24
|
+
} catch { return undefined; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type CompanionRole = "supervisor" | "leg" | "disabled";
|
|
28
|
+
export interface CompanionPresence {
|
|
29
|
+
version: 1;
|
|
30
|
+
provider: "pi-persona";
|
|
31
|
+
role: CompanionRole;
|
|
32
|
+
capabilities: { memory: 1 | false; activity: 1 | false };
|
|
33
|
+
}
|
|
34
|
+
export interface CompanionActivity {
|
|
35
|
+
version: 1;
|
|
36
|
+
producerId: "pi-persona-mind";
|
|
37
|
+
activityId: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
phase: "inspect" | "stage" | "apply";
|
|
40
|
+
state: "running" | "staged" | "done" | "failed";
|
|
41
|
+
target: "project" | "project-long" | "persona" | "shared";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function companionPresence(role: CompanionRole): CompanionPresence {
|
|
45
|
+
return { version: 1, provider: "pi-persona", role, capabilities: { memory: role === "disabled" ? false : 1, activity: role === "supervisor" ? 1 : false } };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Closed records cannot accidentally export raw memory, model claims, or prompts into the tree. */
|
|
49
|
+
export function parseCompanionActivity(value: unknown): CompanionActivity | undefined {
|
|
50
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
51
|
+
const prototype = Object.getPrototypeOf(value);
|
|
52
|
+
if (prototype !== null && prototype !== Object.prototype) return undefined;
|
|
53
|
+
const allowed = new Set(["version", "producerId", "activityId", "sessionId", "phase", "state", "target"]);
|
|
54
|
+
if (Reflect.ownKeys(value).some((key) => typeof key !== "string" || !allowed.has(key))) return undefined;
|
|
55
|
+
const activity = value as Record<string, unknown>;
|
|
56
|
+
if (activity.version !== 1 || activity.producerId !== "pi-persona-mind") return undefined;
|
|
57
|
+
if (typeof activity.activityId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$/.test(activity.activityId)) return undefined;
|
|
58
|
+
if (typeof activity.sessionId !== "string" || activity.sessionId.length < 1 || activity.sessionId.length > 256 || /[\s\u0000-\u001f\u007f]/u.test(activity.sessionId)) return undefined;
|
|
59
|
+
if (activity.phase !== "inspect" && activity.phase !== "stage" && activity.phase !== "apply") return undefined;
|
|
60
|
+
if (activity.state !== "running" && activity.state !== "staged" && activity.state !== "done" && activity.state !== "failed") return undefined;
|
|
61
|
+
if (activity.state === "staged" && activity.phase !== "stage") return undefined;
|
|
62
|
+
if (activity.target !== "project" && activity.target !== "project-long" && activity.target !== "persona" && activity.target !== "shared") return undefined;
|
|
63
|
+
return { version: 1, producerId: "pi-persona-mind", activityId: activity.activityId, sessionId: activity.sessionId, phase: activity.phase, state: activity.state, target: activity.target };
|
|
64
|
+
}
|