zob-harness 0.15.1 → 0.15.3
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/.pi/extensions/zob-harness/index.ts +1 -0
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer-status.ts +92 -0
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts +4 -1
- package/.pi/extensions/zob-harness/src/runtime/commands/zlive.ts +11 -6
- package/.pi/extensions/zob-harness/src/runtime/events.ts +14 -16
- package/.pi/extensions/zob-harness/src/runtime/schemas.ts +2 -2
- package/.pi/extensions/zob-harness/src/runtime/state.ts +5 -0
- package/.pi/extensions/zob-harness/src/runtime/tools-coms.ts +53 -16
- package/.pi/extensions/zob-harness/src/runtime/zpeer-events.ts +53 -0
- package/package.json +1 -1
- package/scripts/zpeer-local-e2e-smoke.mjs +7 -1
- package/scripts/zpeer-static-smoke.mjs +7 -2
|
@@ -617,6 +617,7 @@ export { ZobPendingReplies } from "./src/domains/coms/coms-v2/pending-replies.js
|
|
|
617
617
|
export { buildZobLiveResponseCapture, buildZobLiveResponseEnvelope } from "./src/domains/coms/coms-v2/response-capture.js";
|
|
618
618
|
export { appendLiveSendRequestedRef, appendLiveDeliveredStatus, appendLiveRunningStatus, appendLiveCompletedRef, appendLiveErrorStatus, appendPeerStaleStatus } from "./src/domains/coms/coms-v2/ledger-bridge.js";
|
|
619
619
|
export { redactZobComsText, writeZobComsRedactedCapture } from "./src/domains/coms/coms-v2/transcript-capture.js";
|
|
620
|
+
export { annotateZpeerStatus, isZpeerTerminalStatus, shouldAcceptZpeerStatusUpdate, zpeerStatusRank } from "./src/domains/coms/coms-v2/zpeer-status.js";
|
|
620
621
|
export type { ZobLiveEnvelope, ZobLiveEnvelopeType } from "./src/domains/coms/coms-v2/envelope.js";
|
|
621
622
|
export type { ZobComsTranscriptCapturePolicy, ZobComsTranscriptMode, ZobComsTranscriptRetentionClass, ZobComsTransportMode, ZobComsV2Policy, ZobLivePeerCard, ZobLivePeerStatus, ZobLivePresenceSummary, ZobLiveRegistrySnapshot } from "./src/domains/coms/coms-v2/types.js";
|
|
622
623
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export interface ZpeerStatusComparable {
|
|
2
|
+
status?: string;
|
|
3
|
+
kind?: string;
|
|
4
|
+
at?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const ZPEER_TERMINAL_STATUSES = new Set([
|
|
8
|
+
"reply",
|
|
9
|
+
"completed",
|
|
10
|
+
"response_sent",
|
|
11
|
+
"blocked",
|
|
12
|
+
"error",
|
|
13
|
+
"timeout",
|
|
14
|
+
"expired",
|
|
15
|
+
"required_response_expired",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const ZPEER_STATUS_RANKS: Record<string, number> = {
|
|
19
|
+
status: 0,
|
|
20
|
+
attempt: 5,
|
|
21
|
+
heartbeat: 5,
|
|
22
|
+
sent: 10,
|
|
23
|
+
delivered: 20,
|
|
24
|
+
urgent_delivered: 25,
|
|
25
|
+
force_accepted: 25,
|
|
26
|
+
force_downgraded: 25,
|
|
27
|
+
required_response_reinject: 30,
|
|
28
|
+
inbound: 35,
|
|
29
|
+
prompt_received: 35,
|
|
30
|
+
waiting: 40,
|
|
31
|
+
response_sent: 90,
|
|
32
|
+
reply: 100,
|
|
33
|
+
completed: 100,
|
|
34
|
+
blocked: 100,
|
|
35
|
+
force_blocked: 100,
|
|
36
|
+
error: 100,
|
|
37
|
+
timeout: 100,
|
|
38
|
+
expired: 100,
|
|
39
|
+
required_response_expired: 100,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function zpeerStatusKey(input: ZpeerStatusComparable): string | undefined {
|
|
43
|
+
return input.status ?? input.kind;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parsedTime(value: string | undefined): number | undefined {
|
|
47
|
+
if (!value) return undefined;
|
|
48
|
+
const parsed = Date.parse(value);
|
|
49
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function isZpeerTerminalStatus(status: string | undefined, kind?: string): boolean {
|
|
53
|
+
const primary = status ?? kind;
|
|
54
|
+
return Boolean((primary && ZPEER_TERMINAL_STATUSES.has(primary)) || (kind && ZPEER_TERMINAL_STATUSES.has(kind)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function zpeerStatusRank(status: string | undefined, kind?: string): number {
|
|
58
|
+
const primary = status ?? kind;
|
|
59
|
+
if (primary && ZPEER_STATUS_RANKS[primary] !== undefined) return ZPEER_STATUS_RANKS[primary];
|
|
60
|
+
if (kind && ZPEER_STATUS_RANKS[kind] !== undefined) return ZPEER_STATUS_RANKS[kind];
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function shouldAcceptZpeerStatusUpdate(current: ZpeerStatusComparable | undefined, incoming: ZpeerStatusComparable): boolean {
|
|
65
|
+
if (!current) return true;
|
|
66
|
+
|
|
67
|
+
const currentTerminal = isZpeerTerminalStatus(current.status, current.kind);
|
|
68
|
+
const incomingTerminal = isZpeerTerminalStatus(incoming.status, incoming.kind);
|
|
69
|
+
if (currentTerminal && !incomingTerminal) return false;
|
|
70
|
+
if (!currentTerminal && incomingTerminal) return true;
|
|
71
|
+
|
|
72
|
+
const currentRank = zpeerStatusRank(current.status, current.kind);
|
|
73
|
+
const incomingRank = zpeerStatusRank(incoming.status, incoming.kind);
|
|
74
|
+
if (incomingRank > currentRank) return true;
|
|
75
|
+
if (incomingRank < currentRank) return false;
|
|
76
|
+
|
|
77
|
+
const currentAt = parsedTime(current.at);
|
|
78
|
+
const incomingAt = parsedTime(incoming.at);
|
|
79
|
+
if (currentAt !== undefined && incomingAt !== undefined) return incomingAt >= currentAt;
|
|
80
|
+
|
|
81
|
+
const currentKey = zpeerStatusKey(current);
|
|
82
|
+
const incomingKey = zpeerStatusKey(incoming);
|
|
83
|
+
return currentKey === incomingKey;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function annotateZpeerStatus<T extends ZpeerStatusComparable>(event: T): T & { terminal: boolean; statusRank: number } {
|
|
87
|
+
return {
|
|
88
|
+
...event,
|
|
89
|
+
terminal: isZpeerTerminalStatus(event.status, event.kind),
|
|
90
|
+
statusRank: zpeerStatusRank(event.status, event.kind),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -10,6 +10,7 @@ import { validateZobComsEdge } from "../../topology/coms.js";
|
|
|
10
10
|
import { loadTeamDefinition, validateTeamDefinition } from "../../topology/teams.js";
|
|
11
11
|
import { loadZteamManifest, zteamAllowsZpeerContact } from "../zagents.js";
|
|
12
12
|
import { sha256 } from "../../../core/utils/hashing.js";
|
|
13
|
+
import { annotateZpeerStatus } from "./zpeer-status.js";
|
|
13
14
|
|
|
14
15
|
const DEFAULT_ROOM_ID = "default";
|
|
15
16
|
const ALIAS_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{1,31}$/;
|
|
@@ -67,6 +68,8 @@ export interface ZpeerSendResult {
|
|
|
67
68
|
deliveryMethod?: "local_socket" | "tmux_sendkeys";
|
|
68
69
|
fallback_delivery?: boolean;
|
|
69
70
|
best_effort?: boolean;
|
|
71
|
+
terminal?: boolean;
|
|
72
|
+
statusRank?: number;
|
|
70
73
|
bodyStored: false;
|
|
71
74
|
}
|
|
72
75
|
|
|
@@ -620,7 +623,7 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
|
|
|
620
623
|
deliveryMethod: result.deliveryMethod,
|
|
621
624
|
fallbackDelivery: result.fallback_delivery,
|
|
622
625
|
});
|
|
623
|
-
return { roomId, priority, interruptMode, interruptReasonHash, requireResponse: requireResponse || undefined, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, ...result };
|
|
626
|
+
return annotateZpeerStatus({ roomId, priority, interruptMode, interruptReasonHash, requireResponse: requireResponse || undefined, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, ...result });
|
|
624
627
|
};
|
|
625
628
|
|
|
626
629
|
if (priority === "force" && !interruptReasonHash) return finish("attempt", { status: "blocked", reason: "force interrupt requires reason hash", targetAlias: targetAlias ?? undefined, taskHash, interruptStatus: "force_blocked", bodyStored: false });
|
|
@@ -17,6 +17,7 @@ import { loadActiveZagentScopedMode } from "../events.js";
|
|
|
17
17
|
import { resolveRuleProfile } from "../../domains/governance/rules.js";
|
|
18
18
|
import type { HarnessRuntimeState } from "../state.js";
|
|
19
19
|
import { applyMode, renderHarnessWidget } from "../widget.js";
|
|
20
|
+
import { recordZpeerRuntimeEvent } from "../zpeer-events.js";
|
|
20
21
|
|
|
21
22
|
function zpeerCommandProfileId(ctx: ExtensionCommandContext): string {
|
|
22
23
|
const sessionIdentity = ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
|
|
@@ -1140,18 +1141,21 @@ async function executeZteamTmuxActionPlan(_repoRoot: string, plan: ZteamTmuxActi
|
|
|
1140
1141
|
}
|
|
1141
1142
|
|
|
1142
1143
|
export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeState): void {
|
|
1143
|
-
const rememberZpeerEvent = (event: { kind: NonNullable<typeof state.zobLive.lastEvent>["kind"]; roomId?: string; fromAlias?: string; toAlias?: string; status: string; reason?: string; msgId?: string; taskHash?: string; outputHash?: string; priority?: ZpeerInterruptPriority; interruptMode?: ZpeerInterruptMode; interruptStatus?: ZpeerInterruptStatus }):
|
|
1144
|
-
|
|
1144
|
+
const rememberZpeerEvent = (event: { kind: NonNullable<typeof state.zobLive.lastEvent>["kind"]; roomId?: string; fromAlias?: string; toAlias?: string; status: string; reason?: string; msgId?: string; taskHash?: string; outputHash?: string; priority?: ZpeerInterruptPriority; interruptMode?: ZpeerInterruptMode; interruptStatus?: ZpeerInterruptStatus }): NonNullable<typeof state.zobLive.lastEvent> | undefined => {
|
|
1145
|
+
const recorded = recordZpeerRuntimeEvent(state, event);
|
|
1146
|
+
return recorded.accepted ? recorded.event : undefined;
|
|
1145
1147
|
};
|
|
1146
1148
|
|
|
1147
|
-
const emitZpeerEvent = (event: Parameters<typeof rememberZpeerEvent>[0]):
|
|
1148
|
-
rememberZpeerEvent(event);
|
|
1149
|
+
const emitZpeerEvent = (event: Parameters<typeof rememberZpeerEvent>[0]): boolean => {
|
|
1150
|
+
const recorded = rememberZpeerEvent(event);
|
|
1151
|
+
if (!recorded) return false;
|
|
1149
1152
|
void pi.sendMessage({
|
|
1150
1153
|
customType: "zob-zpeer-event",
|
|
1151
1154
|
content: `ZPeer ${event.kind} ${event.fromAlias ? `@${event.fromAlias}` : "?"} → ${event.toAlias ? `@${event.toAlias}` : "?"} ${event.status}`,
|
|
1152
1155
|
display: true,
|
|
1153
|
-
details: { ...
|
|
1154
|
-
}, { triggerTurn: false });
|
|
1156
|
+
details: { ...recorded },
|
|
1157
|
+
}, { triggerTurn: false, deliverAs: "nextTurn" });
|
|
1158
|
+
return true;
|
|
1155
1159
|
};
|
|
1156
1160
|
|
|
1157
1161
|
pi.registerCommand("zagent", {
|
|
@@ -1682,6 +1686,7 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
|
|
|
1682
1686
|
maxReinjects: sendMode.maxReinjects,
|
|
1683
1687
|
onFeedback: (feedback) => {
|
|
1684
1688
|
feedbackEmittedTerminal = feedback.result.status === "waiting" || feedback.result.status === "reply" || feedback.result.status === "completed" || feedback.result.status === "blocked" || feedback.result.status === "error" || feedback.result.status === "timeout" || feedback.result.status === "expired" || feedback.result.status === "required_response_expired";
|
|
1689
|
+
if (feedback.kind === "waiting" && (sendMode.requireResponse === true || sendMode.mode !== "async")) return;
|
|
1685
1690
|
const feedbackRoomId = feedback.result.roomId ?? eventRoomId;
|
|
1686
1691
|
emitZpeerEvent({ kind: feedback.kind, roomId: feedbackRoomId, fromAlias: state.zobLive.peerCard ? peerAliasInRoom(state.zobLive.peerCard, feedbackRoomId) ?? eventFromAlias : eventFromAlias, toAlias: feedback.result.targetAlias ?? targetAlias, status: feedback.result.status, reason: feedback.result.reason, msgId: feedback.result.msgId, taskHash: feedback.result.taskHash, outputHash: feedback.result.outputHash, priority: feedback.result.priority ?? sendMode.priority, interruptMode: feedback.result.interruptMode ?? sendMode.interruptMode, interruptStatus: feedback.result.interruptStatus });
|
|
1687
1692
|
},
|
|
@@ -40,6 +40,7 @@ import { capturePlanArtifact } from "./plan-capture.js";
|
|
|
40
40
|
import { redactPlanTodosBlockForDisplay } from "../domains/plan/plan-todos.js";
|
|
41
41
|
import { applyMode, renderHarnessWidget } from "./widget.js";
|
|
42
42
|
import { createStopRestoreCandidate, markStopRestoreAssistantMessage, markStopRestoreToolVisible } from "./stop-restore.js";
|
|
43
|
+
import { recordZpeerRuntimeEvent } from "./zpeer-events.js";
|
|
43
44
|
|
|
44
45
|
function safelyUpdateZobLivePeer(repoRoot: string, action: "register" | "touch" | "unregister"): void {
|
|
45
46
|
try {
|
|
@@ -51,14 +52,8 @@ function safelyUpdateZobLivePeer(repoRoot: string, action: "register" | "touch"
|
|
|
51
52
|
}
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function setZpeerLastEvent(state: HarnessRuntimeState, event: Omit<ZobLiveLastEvent, "at" | "localOnly" | "networkEnabled" | "bodyStored"> & { at?: string }):
|
|
55
|
-
state.
|
|
56
|
-
...event,
|
|
57
|
-
at: event.at ?? new Date().toISOString(),
|
|
58
|
-
localOnly: true,
|
|
59
|
-
networkEnabled: false,
|
|
60
|
-
bodyStored: false,
|
|
61
|
-
};
|
|
55
|
+
function setZpeerLastEvent(state: HarnessRuntimeState, event: Omit<ZobLiveLastEvent, "at" | "localOnly" | "networkEnabled" | "bodyStored" | "terminal" | "statusRank" | "superseded" | "supersededByStatus"> & { at?: string }): ZobLiveLastEvent {
|
|
56
|
+
return recordZpeerRuntimeEvent(state, event).event;
|
|
62
57
|
}
|
|
63
58
|
|
|
64
59
|
function clearPassivePeerWaitForResponse(state: HarnessRuntimeState, envelope: { msgId?: string; runId?: string; sender?: string; type?: string }): void {
|
|
@@ -244,7 +239,7 @@ function handleInboundZpeerPrompt(pi: ExtensionAPI, state: HarnessRuntimeState,
|
|
|
244
239
|
content: priority === "normal" ? "ZPeer inbound prompt received (transient body delivered only to agent turn)" : `ZPeer ${priority} inbound prompt received (transient body delivered only to agent turn)`,
|
|
245
240
|
display: true,
|
|
246
241
|
details: { kind: "inbound", roomId, fromAlias: envelope.sender, toAlias: envelope.receiver, status: interruptStatus ?? "prompt_received", msgId: envelope.msgId, taskHash: envelope.taskHash, priority, interruptMode, interruptStatus, bodyStored: false, localOnly: true, networkEnabled: false },
|
|
247
|
-
}, { triggerTurn: false });
|
|
242
|
+
}, { triggerTurn: false, deliverAs: "nextTurn" });
|
|
248
243
|
void pi.sendMessage({
|
|
249
244
|
customType: "zob-coms-inbound",
|
|
250
245
|
content: envelope.transientPrompt ?? "",
|
|
@@ -620,7 +615,7 @@ function formatZpeerLastEvent(event: ZobLiveLastEvent | undefined): string {
|
|
|
620
615
|
|
|
621
616
|
function buildZpeerAwarenessPrompt(state: HarnessRuntimeState, repoRoot: string): string {
|
|
622
617
|
if (!state.zobLive.peerCard) {
|
|
623
|
-
return "\n\nZPEER AWARENESS\n- local peer endpoint: unavailable this turn\n- Use zpeer_ask with mode=\"async\" or /zpeer only when useful or user-requested for peer coordination; avoid spam/loops and do not invent hidden worker-to-worker chat.";
|
|
618
|
+
return "\n\nZPEER AWARENESS\n- local peer endpoint: unavailable this turn\n- Use zpeer_ask with mode=\"async\" or /zpeer only when useful or user-requested for peer coordination; avoid spam/loops and do not invent hidden worker-to-worker chat.\n- If the user explicitly asks for a reply, status, or confirmation, use requireResponse=true with mode=\"await\" or mode=\"long\"; status=waiting is delivery-only, not evidence that the peer is working or done.";
|
|
624
619
|
}
|
|
625
620
|
const summaries = buildZpeerPeerRoomSummaries(repoRoot, state.zobLive.peerCard);
|
|
626
621
|
const activeSummary = summaries.find((summary) => summary.active) ?? summaries[0];
|
|
@@ -637,7 +632,7 @@ function buildZpeerAwarenessPrompt(state: HarnessRuntimeState, repoRoot: string)
|
|
|
637
632
|
const activePeerAliases = (activeSummary?.onlineAliases ?? []).filter((alias) => alias !== activeSelfAlias).slice(0, 8).map((alias) => `@${alias}`);
|
|
638
633
|
const activeUnavailable = (activeSummary?.stale ?? 0) + (activeSummary?.offline ?? 0);
|
|
639
634
|
const activeDuplicateLine = activeSummary && activeSummary.duplicateAliases.length > 0 ? `\n- duplicate live aliases: ${activeSummary.duplicateAliases.map((alias) => `@${alias}`).join(", ")}` : "";
|
|
640
|
-
return `\n\nZPEER AWARENESS (transient, rebuilt each turn)\n- active room: ${activeSummary?.roomId ?? "default"}\n- memberships: ${memberships}\n- self: @${activeSelfAlias}\n- online peers: ${activePeerAliases.join(", ") || "none"}\n- unavailable peers: ${activeUnavailable} (stale=${activeSummary?.stale ?? 0}, offline=${activeSummary?.offline ?? 0})${activeDuplicateLine}\n- rooms:\n${roomLines.join("\n") || " - none"}\n- Use zpeer_ask with explicit roomId when targeting a non-active room.\n- posture: local_socket-only, room-scoped, hash-only durable ledgers, bodyStored=false, networkEnabled=false\n- For non-trivial review/debug/planning peer coordination, agents may use zpeer_ask with mode=\"async\" so the request is visible, governed, and non-blocking; /zpeer remains the interactive command path.\n- Passive wait rule: if the only remaining action is waiting for ZPeer/coms replies, stop the turn and remain idle; do not poll, call tools, or continue just to wait.\n- Use ZPeer only when useful or user-requested; avoid spam, duplicate asks, and reply loops; do not use it for hidden free chat or to bypass topology/safety gates.\n- Raw ZPeer bodies are transient; durable records must remain hash-only/bodyStored=false.\n- last ZPeer event: ${formatZpeerLastEvent(state.zobLive.lastEvent)}`;
|
|
635
|
+
return `\n\nZPEER AWARENESS (transient, rebuilt each turn)\n- active room: ${activeSummary?.roomId ?? "default"}\n- memberships: ${memberships}\n- self: @${activeSelfAlias}\n- online peers: ${activePeerAliases.join(", ") || "none"}\n- unavailable peers: ${activeUnavailable} (stale=${activeSummary?.stale ?? 0}, offline=${activeSummary?.offline ?? 0})${activeDuplicateLine}\n- rooms:\n${roomLines.join("\n") || " - none"}\n- Use zpeer_ask with explicit roomId when targeting a non-active room.\n- posture: local_socket-only, room-scoped, hash-only durable ledgers, bodyStored=false, networkEnabled=false\n- For non-trivial review/debug/planning peer coordination, agents may use zpeer_ask with mode=\"async\" so the request is visible, governed, and non-blocking; /zpeer remains the interactive command path.\n- If the user explicitly asks for a reply, status, or confirmation, use requireResponse=true with mode=\"await\" or mode=\"long\"; status=waiting is delivery-only, not evidence that the peer is working or done.\n- Passive wait rule: if the only remaining action is waiting for ZPeer/coms replies, stop the turn and remain idle; do not poll, call tools, or continue just to wait.\n- Use ZPeer only when useful or user-requested; avoid spam, duplicate asks, and reply loops; do not use it for hidden free chat or to bypass topology/safety gates.\n- Raw ZPeer bodies are transient; durable records must remain hash-only/bodyStored=false.\n- last ZPeer event: ${formatZpeerLastEvent(state.zobLive.lastEvent)}`;
|
|
641
636
|
}
|
|
642
637
|
|
|
643
638
|
const SAME_AGENT_MODE_INTENT_PROMPT = [
|
|
@@ -785,7 +780,7 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
|
|
|
785
780
|
content: envelope.errorCode === "zpeer_required_response_expired" ? "ZPeer required response expired" : "ZPeer error received",
|
|
786
781
|
display: true,
|
|
787
782
|
details: { kind: state.zobLive.lastEvent?.kind, roomId: state.zobLive.lastEvent?.roomId, fromAlias: envelope.sender, toAlias: envelope.receiver, status: state.zobLive.lastEvent?.status, msgId: envelope.msgId, taskHash: envelope.taskHash, errorHash: envelope.errorHash, bodyStored: false, localOnly: true, networkEnabled: false },
|
|
788
|
-
}, { triggerTurn: false });
|
|
783
|
+
}, { triggerTurn: false, deliverAs: "nextTurn" });
|
|
789
784
|
return buildZobLiveAckEnvelope(envelope);
|
|
790
785
|
}
|
|
791
786
|
if (envelope.type === "response") {
|
|
@@ -807,7 +802,7 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
|
|
|
807
802
|
content: "ZPeer reply received (transient response available; durable records remain hash-only)",
|
|
808
803
|
display: true,
|
|
809
804
|
details: { kind: "reply", roomId: state.zobLive.lastEvent?.roomId, fromAlias: envelope.sender, toAlias: envelope.receiver, status: "reply", msgId: envelope.msgId, taskHash: envelope.taskHash, outputHash: envelope.outputHash, bodyStored: false, localOnly: true, networkEnabled: false },
|
|
810
|
-
}, { triggerTurn: false });
|
|
805
|
+
}, { triggerTurn: false, deliverAs: "nextTurn" });
|
|
811
806
|
if (!completedActiveWait && envelope.transientResponse) {
|
|
812
807
|
void pi.sendMessage({
|
|
813
808
|
customType: "zob-zpeer-response",
|
|
@@ -1060,7 +1055,10 @@ async function sendInboundZobLiveResponse(pi: ExtensionAPI, state: HarnessRuntim
|
|
|
1060
1055
|
const activeInbound = activeZpeerInboundForResponse(state);
|
|
1061
1056
|
const inbound = activeInbound ?? state.zobLive.inbound;
|
|
1062
1057
|
if (!inbound || inbound.responseSent || !inbound.envelope.replyEndpoint) return;
|
|
1063
|
-
|
|
1058
|
+
// ZOB-COMS-GUARDS: a late reply after expiry is now permitted (the watchdog
|
|
1059
|
+
// already stopped reinjecting; sender-side pendingReplies resolves to no-op).
|
|
1060
|
+
// Dropping this early-out recovers nudge/reply paths (e.g. a late oracle
|
|
1061
|
+
// review) without weakening the real anti-abuse (reinject cap + sender expire).
|
|
1064
1062
|
if (state.zobLive.inboundByMsgId && !activeInbound) return;
|
|
1065
1063
|
const responseText = latestAssistantText(event);
|
|
1066
1064
|
if (!responseText.trim()) return;
|
|
@@ -1103,7 +1101,7 @@ async function sendInboundZobLiveResponse(pi: ExtensionAPI, state: HarnessRuntim
|
|
|
1103
1101
|
content: "ZPeer response sent (transient response delivered over local socket; durable records remain hash-only)",
|
|
1104
1102
|
display: true,
|
|
1105
1103
|
details: { kind: "response_sent", roomId: state.zobLive.lastEvent?.roomId, fromAlias: inbound.envelope.receiver, toAlias: inbound.envelope.sender, status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash: responseEnvelope.outputHash, priority: activeInbound?.priority, interruptMode: activeInbound?.interruptMode, bodyStored: false, localOnly: true, networkEnabled: false },
|
|
1106
|
-
}, { triggerTurn: false });
|
|
1104
|
+
}, { triggerTurn: false, deliverAs: "nextTurn" });
|
|
1107
1105
|
}
|
|
1108
1106
|
|
|
1109
1107
|
export function registerHarnessEvents(pi: ExtensionAPI, state: HarnessRuntimeState): void {
|
|
@@ -1118,7 +1116,7 @@ export function registerHarnessEvents(pi: ExtensionAPI, state: HarnessRuntimeSta
|
|
|
1118
1116
|
const taskHash = typeof details.taskHash === "string" ? details.taskHash : undefined;
|
|
1119
1117
|
const outputHash = typeof details.outputHash === "string" ? details.outputHash : undefined;
|
|
1120
1118
|
const route = fromAlias || toAlias ? `${fromAlias ? `@${fromAlias}` : "?"} → ${toAlias ? `@${toAlias}` : "?"}` : "room status";
|
|
1121
|
-
const statusColor = status === "completed" || status === "sent" || status === "prompt_received" || status === "response_sent" ? "success" : status === "blocked" || status === "timeout" || status === "error" || status === "required_response_expired" ? "warning" : "muted";
|
|
1119
|
+
const statusColor = status === "reply" || status === "completed" || status === "sent" || status === "delivered" || status === "prompt_received" || status === "response_sent" ? "success" : status === "blocked" || status === "timeout" || status === "error" || status === "expired" || status === "required_response_expired" ? "warning" : "muted";
|
|
1122
1120
|
const line = [
|
|
1123
1121
|
theme.fg("accent", "◆ ZPeer"),
|
|
1124
1122
|
theme.fg("muted", kind),
|
|
@@ -354,13 +354,13 @@ const ZpeerAskParams = Type.Object({
|
|
|
354
354
|
targetAlias: Type.String({ description: "ZPeer target alias in the current local room. May include or omit the leading @." }),
|
|
355
355
|
message: Type.String({ description: "Transient peer request body. Used only for local live delivery; never persisted in durable ledgers or reports." }),
|
|
356
356
|
roomId: Type.Optional(Type.String({ description: "Optional ZPeer room id. Defaults to the current active local room." })),
|
|
357
|
-
mode: Type.Optional(StringEnum(["async", "await", "long"] as const, { description: "Send mode. Default async for
|
|
357
|
+
mode: Type.Optional(StringEnum(["async", "await", "long"] as const, { description: "Send mode. Default async for non-blocking coordination; when an actual reply/status is required, use await or long with requireResponse=true.", default: "async" })),
|
|
358
358
|
reason: Type.Optional(Type.String({ description: "Optional transient coordination/interrupt reason. Required for force; hashed only in visible metadata; raw value is not persisted." })),
|
|
359
359
|
urgency: Type.Optional(StringEnum(["normal", "urgent", "force"] as const, { description: "ZPeer delivery priority. normal=follow-up, urgent=steer, force=controlled abort+steer when policy allows." })),
|
|
360
360
|
force: Type.Optional(Type.Boolean({ description: "Alias for urgency=force. Requires reason and remains local/hash-only." })),
|
|
361
361
|
interruptMode: Type.Optional(StringEnum(["none", "steer", "abort"] as const, { description: "Requested interrupt mode. Derived from urgency when omitted; invalid broadening is blocked by runtime." })),
|
|
362
362
|
timeoutMs: Type.Optional(Type.Number({ description: "Bounded reply wait timeout for await/long modes, and required-response expiration when requireResponse=true; capped by runtime." })),
|
|
363
|
-
requireResponse: Type.Optional(Type.Boolean({ description: "Opt in to a real msgId-correlated required response.
|
|
363
|
+
requireResponse: Type.Optional(Type.Boolean({ description: "Opt in to a real msgId-correlated required response. Set true whenever the user asks for a reply, status, or confirmation; the sender waits only for the exact msgId reply and expires explicitly." })),
|
|
364
364
|
maxReinjects: Type.Optional(Type.Number({ description: "Bounded receiver reminder budget for requireResponse. Default 1, capped 0..3." })),
|
|
365
365
|
});
|
|
366
366
|
|
|
@@ -49,6 +49,10 @@ export interface ZobLiveLastEvent {
|
|
|
49
49
|
priority?: ZpeerInterruptPriority;
|
|
50
50
|
interruptMode?: ZpeerInterruptMode;
|
|
51
51
|
interruptStatus?: ZpeerInterruptStatus;
|
|
52
|
+
terminal?: boolean;
|
|
53
|
+
statusRank?: number;
|
|
54
|
+
superseded?: boolean;
|
|
55
|
+
supersededByStatus?: string;
|
|
52
56
|
at: string;
|
|
53
57
|
localOnly: true;
|
|
54
58
|
networkEnabled: false;
|
|
@@ -98,6 +102,7 @@ export interface ZobLiveRuntimeState {
|
|
|
98
102
|
inboundQueue?: string[];
|
|
99
103
|
activeInboundMsgId?: string;
|
|
100
104
|
lastEvent?: ZobLiveLastEvent;
|
|
105
|
+
latestZpeerEventByMsgId?: Record<string, ZobLiveLastEvent>;
|
|
101
106
|
passivePeerWait?: ZobPassivePeerWaitState;
|
|
102
107
|
leaseOwned?: boolean;
|
|
103
108
|
leaseStatus?: "owned" | "blocked" | "unavailable";
|
|
@@ -34,11 +34,23 @@ import {
|
|
|
34
34
|
} from "../domains/topology/coms.js";
|
|
35
35
|
import { loadTeamDefinition, validateTeamDefinition } from "../domains/topology/teams.js";
|
|
36
36
|
import type { HarnessRuntimeState } from "./state.js";
|
|
37
|
+
import { isCurrentZpeerRuntimeEvent, recordZpeerRuntimeEvent } from "./zpeer-events.js";
|
|
37
38
|
|
|
38
39
|
const SHA256_HEX = /^[a-f0-9]{64}$/i;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
// ZOB-COMS-GUARDS: per-agent zpeer rate caps are env-configurable with a finite
|
|
41
|
+
// ceiling (never Infinity) so trusted closed topologies (e.g. project-transposer's
|
|
42
|
+
// 64-agent fan-out) don't trip the default 50/10/3 while real floods are still
|
|
43
|
+
// bounded. Defaults unchanged for open/hostile topologies.
|
|
44
|
+
const clampRate = (v: string | undefined, fallback: number): number => Math.max(1, Math.min(1000, Number.parseInt(v ?? "") || fallback));
|
|
45
|
+
const ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE = clampRate(process.env.ZOB_ZPEER_ASK_RATE_LIMIT_PER_MINUTE, 50);
|
|
46
|
+
const ZPEER_AGENT_URGENT_RATE_LIMIT_PER_MINUTE = clampRate(process.env.ZOB_ZPEER_URGENT_RATE_LIMIT_PER_MINUTE, 10);
|
|
47
|
+
const ZPEER_AGENT_FORCE_RATE_LIMIT_PER_MINUTE = clampRate(process.env.ZOB_ZPEER_FORCE_RATE_LIMIT_PER_MINUTE, 3);
|
|
48
|
+
// ZOB-COMS-GUARDS: the loop-keyword block (reject any zpeer_ask whose message
|
|
49
|
+
// text contains 'zpeer_ask'/'/zpeer') over-fires on trusted topologies whose
|
|
50
|
+
// prompts legitimately quote these tokens. Env-bypassed for trusted topologies;
|
|
51
|
+
// default (unset) keeps current behavior for open/hostile use. Genuine anti-loop
|
|
52
|
+
// (duplicate guard below + reinject cap + sender-side expire) is preserved.
|
|
53
|
+
const ZPEER_RECURSION_KEYWORD_GUARD_DISABLED = /^(1|true|yes|on)$/i.test(process.env.ZOB_ZPEER_DISABLE_RECURSION_KEYWORD_GUARD ?? "");
|
|
42
54
|
|
|
43
55
|
type ZpeerFallbackHookFn = ZpeerFallbackDelivery;
|
|
44
56
|
|
|
@@ -141,7 +153,7 @@ function zpeerAskGuardBlock(state: HarnessRuntimeState, params: ZpeerAskToolPara
|
|
|
141
153
|
if (selfAlias && zpeerAliasesEquivalent(targetAlias, selfAlias)) return "cannot send to self";
|
|
142
154
|
const roomId = safeZpeerRoomId(params.roomId) ?? currentRoomId;
|
|
143
155
|
if (params.roomId && !safeZpeerRoomId(params.roomId)) return "invalid room id";
|
|
144
|
-
if (/\b(zpeer_ask|\/zpeer)\b/i.test(params.message)) return "loop guard blocked recursive ZPeer instruction";
|
|
156
|
+
if (!ZPEER_RECURSION_KEYWORD_GUARD_DISABLED && /\b(zpeer_ask|\/zpeer)\b/i.test(params.message)) return "loop guard blocked recursive ZPeer instruction";
|
|
145
157
|
const messageHash = sha256(params.message);
|
|
146
158
|
const now = Date.now();
|
|
147
159
|
const windowMs = 60_000;
|
|
@@ -263,7 +275,11 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
|
|
|
263
275
|
name: "zpeer_ask",
|
|
264
276
|
label: "ZPeer Ask",
|
|
265
277
|
description: "Ask a visible local ZPeer via the governed room-scoped local_socket path. Defaults to mode=async; raw message/reply bodies are transient and durable metadata is hash-only.",
|
|
266
|
-
promptSnippet: "Use zpeer_ask with mode=\"async\" for
|
|
278
|
+
promptSnippet: "Use zpeer_ask with mode=\"async\" for non-blocking coordination; when an actual reply/status is required, set requireResponse=true and use mode=\"await\" or mode=\"long\".",
|
|
279
|
+
promptGuidelines: [
|
|
280
|
+
"When the user asks for an actual reply, current status, confirmation, or a response requirement, call zpeer_ask with requireResponse=true and mode=\"await\" or mode=\"long\".",
|
|
281
|
+
"Treat zpeer_ask status=waiting as delivery-only: it does not prove the peer read the request, is working, or finished. If nothing else is actionable, stop/idle instead of polling.",
|
|
282
|
+
],
|
|
267
283
|
parameters: ZpeerAskParams,
|
|
268
284
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
269
285
|
if (!state?.zobLive.peerCard) return { content: [{ type: "text", text: "zpeer_ask blocked: current session has not registered a local peer endpoint" }], details: { schema: "zob.zpeer-ask-result.v1", status: "blocked", reason: "local_peer_unavailable", bodyStored: false } };
|
|
@@ -275,16 +291,29 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
|
|
|
275
291
|
const requestedFromAlias = peerAliasInRoom(self, requestedRoomId) ?? self.zpeerAlias;
|
|
276
292
|
const interrupt = normalizeZpeerInterrupt(params);
|
|
277
293
|
const guardReason = interrupt.error ?? zpeerAskGuardBlock(state, params, requestedFromAlias, requestedRoomId, interrupt.priority);
|
|
278
|
-
const
|
|
294
|
+
const queuedZpeerEvents: Array<{ event: NonNullable<HarnessRuntimeState["zobLive"]["lastEvent"]>; content: string; details: Record<string, unknown> }> = [];
|
|
295
|
+
const flushZpeerAskEvents = async (): Promise<void> => {
|
|
296
|
+
for (const queued of queuedZpeerEvents) {
|
|
297
|
+
if (!isCurrentZpeerRuntimeEvent(state, queued.event)) continue;
|
|
298
|
+
await Promise.resolve(pi.sendMessage({
|
|
299
|
+
customType: "zob-zpeer-event",
|
|
300
|
+
content: queued.content,
|
|
301
|
+
display: true,
|
|
302
|
+
details: queued.details,
|
|
303
|
+
}, { triggerTurn: false, deliverAs: "nextTurn" }));
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const emitZpeerAskEvent = (event: { kind: NonNullable<HarnessRuntimeState["zobLive"]["lastEvent"]>["kind"]; status: string; reason?: string; msgId?: string; roomId?: string; taskHash?: string; outputHash?: string; interruptStatus?: ZpeerInterruptStatus }): boolean => {
|
|
279
307
|
const eventRoomId = event.roomId ?? requestedRoomId;
|
|
280
308
|
const fromAlias = peerAliasInRoom(self, eventRoomId) ?? requestedFromAlias;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
309
|
+
const recorded = recordZpeerRuntimeEvent(state, { kind: event.kind, roomId: eventRoomId, fromAlias, toAlias: targetAlias, status: event.status, reason: event.reason, msgId: event.msgId, taskHash: event.taskHash, outputHash: event.outputHash, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus: event.interruptStatus });
|
|
310
|
+
if (!recorded.accepted) return false;
|
|
311
|
+
queuedZpeerEvents.push({
|
|
312
|
+
event: recorded.event,
|
|
284
313
|
content: `ZPeer agent-request @${fromAlias ?? "?"} → @${targetAlias} ${event.status}`,
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
314
|
+
details: { ...recorded.event, source: "agent-request", mode, priority: interrupt.priority, interruptMode: interrupt.interruptMode, bodyStored: false, localOnly: true, networkEnabled: false },
|
|
315
|
+
});
|
|
316
|
+
return true;
|
|
288
317
|
};
|
|
289
318
|
const taskHash = params.message.trim() ? sha256(params.message) : undefined;
|
|
290
319
|
if (guardReason) {
|
|
@@ -293,6 +322,7 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
|
|
|
293
322
|
const result = { schema: "zob.zpeer-ask-result.v1", status: "blocked", reason: guardReason, targetAlias, taskHash, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus, bodyStored: false };
|
|
294
323
|
emitZpeerAskEvent({ kind: "blocked", status: "blocked", reason: guardReason, taskHash, interruptStatus });
|
|
295
324
|
pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-ask.v1", action: "agent_request_blocked", mode, status: "blocked", priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus, reasonHash: sha256(guardReason), targetAliasHash: sha256(targetAlias), roomIdHash: sha256(requestedRoomId), taskHash, reasonInputHash: params.reason ? sha256(params.reason) : undefined, interruptReasonHash: interrupt.interruptReasonHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
|
|
325
|
+
await flushZpeerAskEvents();
|
|
296
326
|
return { content: [{ type: "text", text: `zpeer_ask blocked: ${guardReason}` }], details: result };
|
|
297
327
|
}
|
|
298
328
|
const timeoutMs = boundedZpeerAskTimeoutMs(mode, params.timeoutMs);
|
|
@@ -318,11 +348,12 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
|
|
|
318
348
|
updatePassivePeerWaitState(state, result, { roomId: requestedRoomId, targetAlias });
|
|
319
349
|
pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-ask.v1", action: "agent_request", mode, status: result.status, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus: result.interruptStatus, reasonHash: result.reason ? sha256(result.reason) : undefined, msgId: result.msgId, targetAliasHash: result.targetAlias ? sha256(result.targetAlias) : sha256(targetAlias), roomIdHash: sha256(result.roomId ?? requestedRoomId), taskHash: result.taskHash, outputHash: result.outputHash, reasonInputHash: params.reason ? sha256(params.reason) : undefined, interruptReasonHash: interrupt.interruptReasonHash, requireResponse: requireResponse || undefined, responseRequiredBy: result.responseRequiredBy, responseTimeoutMs: result.responseTimeoutMs, maxReinjects: result.maxReinjects, responseReceived: result.responseReceived, deliveryStatus: result.deliveryStatus, deliveryMethod: result.deliveryMethod, fallbackDelivery: result.fallback_delivery, bestEffort: result.best_effort, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
|
|
320
350
|
const ok = result.status === "reply" || result.status === "completed" || result.status === "waiting" || result.status === "delivered";
|
|
321
|
-
const passiveWaitSuffix = result.status === "waiting" ? " · idle/passive wait: no follow-up turn queued; stop if no other action is actionable" : "";
|
|
351
|
+
const passiveWaitSuffix = result.status === "waiting" ? " · delivery-only: no reply received; not evidence the target is working or done · idle/passive wait: no follow-up turn queued; stop if no other action is actionable" : "";
|
|
322
352
|
const transientReplyText = (result.status === "reply" || result.status === "completed") && result.transientResponse
|
|
323
353
|
? `\n\nTransient ZPeer reply (not stored in .pi/coms):\n${result.transientResponse}`
|
|
324
354
|
: "";
|
|
325
355
|
const interruptSuffix = result.interruptStatus ? ` interrupt=${result.interruptStatus}` : interrupt.priority !== "normal" ? ` priority=${interrupt.priority}` : "";
|
|
356
|
+
await flushZpeerAskEvents();
|
|
326
357
|
return { content: [{ type: "text", text: ok ? `zpeer_ask ${result.status}: @${result.targetAlias ?? targetAlias}${interruptSuffix}${result.outputHash ? ` outputHash=${result.outputHash}` : ""}${passiveWaitSuffix}${transientReplyText}` : `zpeer_ask ${result.status}: ${result.reason ?? "see metadata"}${interruptSuffix}` }], details: { schema: "zob.zpeer-ask-result.v1", mode, ...result } };
|
|
327
358
|
},
|
|
328
359
|
});
|
|
@@ -338,7 +369,13 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
|
|
|
338
369
|
const responseText = params.message ?? "";
|
|
339
370
|
const outputHash = responseText.trim() ? sha256(responseText) : undefined;
|
|
340
371
|
const inbound = msgId ? state?.zobLive.inboundByMsgId?.[msgId] : undefined;
|
|
341
|
-
const block = !state ? "zpeer runtime state unavailable" : !msgId ? "msgId is required" : !responseText.trim() ? "message is required" : !inbound ? "no active inbound ZPeer message for msgId" : inbound.responseSent || inbound.requiredResponseStatus === "replied" ? "ZPeer msgId already answered" :
|
|
372
|
+
const block = !state ? "zpeer runtime state unavailable" : !msgId ? "msgId is required" : !responseText.trim() ? "message is required" : !inbound ? "no active inbound ZPeer message for msgId" : inbound.responseSent || inbound.requiredResponseStatus === "replied" ? "ZPeer msgId already answered" : // ZOB-COMS-GUARDS: a late reply after the requireResponse watchdog expired is
|
|
373
|
+
// now PERMITTED. The watchdog already stops reinjecting once reinjectCount hits
|
|
374
|
+
// maxReinjects (events.ts) and the sender's pendingReplies entry resolves to a
|
|
375
|
+
// no-op on a late envelope, so a late reply is harmless at the sender and
|
|
376
|
+
// recovers nudge/reply paths (e.g. a late oracle review). The 'replied' /
|
|
377
|
+
// responseSent guard above still prevents double-replies.
|
|
378
|
+
!inbound.envelope.replyEndpoint ? "ZPeer inbound msgId has no reply endpoint" : undefined;
|
|
342
379
|
if (block) {
|
|
343
380
|
pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-reply.v1", action: "reply_blocked", status: "blocked", reasonHash: sha256(block), msgId, outputHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
|
|
344
381
|
return { content: [{ type: "text", text: `zpeer_reply blocked: ${block}` }], details: { schema: "zob.zpeer-reply-result.v1", status: "blocked", reason: block, msgId, outputHash, bodyStored: false, localOnly: true, networkEnabled: false } };
|
|
@@ -357,8 +394,8 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
|
|
|
357
394
|
state.zobLive.activeInboundMsgId = undefined;
|
|
358
395
|
state.zobLive.inboundQueue = (state.zobLive.inboundQueue ?? []).filter((candidate) => candidate !== inbound.envelope.msgId);
|
|
359
396
|
const roomId = inbound.envelope.runId?.startsWith("zpeer:") ? inbound.envelope.runId.slice("zpeer:".length) : undefined;
|
|
360
|
-
|
|
361
|
-
void pi.sendMessage({ customType: "zob-zpeer-event", content: "ZPeer explicit reply sent", display: true, details: { ...
|
|
397
|
+
const recorded = recordZpeerRuntimeEvent(state, { kind: "response_sent", roomId, fromAlias: inbound.envelope.receiver, toAlias: inbound.envelope.sender, status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, priority: inbound.priority, interruptMode: inbound.interruptMode });
|
|
398
|
+
if (recorded.accepted) void pi.sendMessage({ customType: "zob-zpeer-event", content: "ZPeer explicit reply sent", display: true, details: { ...recorded.event } }, { triggerTurn: false, deliverAs: "nextTurn" });
|
|
362
399
|
pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-reply.v1", action: "reply", status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, priority: inbound.priority, interruptMode: inbound.interruptMode, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
|
|
363
400
|
return { content: [{ type: "text", text: `zpeer_reply sent: msgId=${inbound.envelope.msgId} outputHash=${outputHash}` }], details: { schema: "zob.zpeer-reply-result.v1", status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, bodyStored: false, localOnly: true, networkEnabled: false } };
|
|
364
401
|
} catch (error) {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { annotateZpeerStatus, shouldAcceptZpeerStatusUpdate } from "../domains/coms/coms-v2/zpeer-status.js";
|
|
2
|
+
import type { HarnessRuntimeState, ZobLiveLastEvent } from "./state.js";
|
|
3
|
+
|
|
4
|
+
export type ZpeerRuntimeEventInput = Omit<ZobLiveLastEvent, "at" | "localOnly" | "networkEnabled" | "bodyStored" | "terminal" | "statusRank" | "superseded" | "supersededByStatus"> & { at?: string };
|
|
5
|
+
|
|
6
|
+
export interface ZpeerRuntimeEventRecordResult {
|
|
7
|
+
accepted: boolean;
|
|
8
|
+
event: ZobLiveLastEvent;
|
|
9
|
+
current?: ZobLiveLastEvent;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function materializeZpeerRuntimeEvent(event: ZpeerRuntimeEventInput): ZobLiveLastEvent {
|
|
13
|
+
return annotateZpeerStatus({
|
|
14
|
+
...event,
|
|
15
|
+
at: event.at ?? new Date().toISOString(),
|
|
16
|
+
localOnly: true as const,
|
|
17
|
+
networkEnabled: false as const,
|
|
18
|
+
bodyStored: false as const,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function recordZpeerRuntimeEvent(state: HarnessRuntimeState, event: ZpeerRuntimeEventInput): ZpeerRuntimeEventRecordResult {
|
|
23
|
+
const next = materializeZpeerRuntimeEvent(event);
|
|
24
|
+
if (!next.msgId) {
|
|
25
|
+
state.zobLive.lastEvent = next;
|
|
26
|
+
return { accepted: true, event: next };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
state.zobLive.latestZpeerEventByMsgId ??= {};
|
|
30
|
+
const current = state.zobLive.latestZpeerEventByMsgId[next.msgId];
|
|
31
|
+
if (!shouldAcceptZpeerStatusUpdate(current, next)) {
|
|
32
|
+
return {
|
|
33
|
+
accepted: false,
|
|
34
|
+
current,
|
|
35
|
+
event: {
|
|
36
|
+
...next,
|
|
37
|
+
superseded: true,
|
|
38
|
+
supersededByStatus: current?.status,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
state.zobLive.latestZpeerEventByMsgId[next.msgId] = next;
|
|
44
|
+
state.zobLive.lastEvent = next;
|
|
45
|
+
return { accepted: true, event: next, current: next };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isCurrentZpeerRuntimeEvent(state: HarnessRuntimeState, event: ZobLiveLastEvent): boolean {
|
|
49
|
+
if (!event.msgId) return true;
|
|
50
|
+
const current = state.zobLive.latestZpeerEventByMsgId?.[event.msgId];
|
|
51
|
+
if (!current) return true;
|
|
52
|
+
return current.at === event.at && current.status === event.status && current.kind === event.kind;
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zob-harness",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A governed Agent Factory for Pi: launch communicating agent teams, run tmux-backed factories, validate artifacts, and package repeatable workflows.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -471,10 +471,11 @@ async function main() {
|
|
|
471
471
|
const registeredTools = new Map();
|
|
472
472
|
const appendEntries = [];
|
|
473
473
|
const feedMessages = [];
|
|
474
|
+
const feedDeliveries = [];
|
|
474
475
|
const mockPi = {
|
|
475
476
|
registerTool: (tool) => registeredTools.set(tool.name, tool),
|
|
476
477
|
appendEntry: (customType, data) => appendEntries.push({ customType, data }),
|
|
477
|
-
sendMessage: (message) => { feedMessages.push(message); return Promise.resolve(); },
|
|
478
|
+
sendMessage: (message, options) => { feedMessages.push(message); feedDeliveries.push({ message, options }); return Promise.resolve(); },
|
|
478
479
|
};
|
|
479
480
|
const toolState = { zobLive: { peerCard: alpha, pendingReplies: { wait: waitForReply } } };
|
|
480
481
|
toolsComs.registerComsTools(mockPi, toolState);
|
|
@@ -488,6 +489,9 @@ async function main() {
|
|
|
488
489
|
assert(JSON.stringify(toolResult?.content ?? '').includes('idle/passive wait: no follow-up turn queued'), 'zpeer_ask async waiting result must tell the agent to idle instead of polling/continuing just to wait');
|
|
489
490
|
const asyncWaitingFeed = feedMessages.filter((item) => item.customType === 'zob-zpeer-event' && item.details?.source === 'agent-request' && item.details?.status === 'waiting' && item.details?.msgId === toolResult?.details?.msgId);
|
|
490
491
|
assert(asyncWaitingFeed.length === 1, `zpeer_ask async must emit exactly one compact waiting feed event, got ${asyncWaitingFeed.length}`);
|
|
492
|
+
const asyncWaitingDelivery = feedDeliveries.find((item) => item.message.details?.source === 'agent-request' && item.message.details?.msgId === toolResult?.details?.msgId);
|
|
493
|
+
assert(asyncWaitingDelivery?.options?.triggerTurn === false && asyncWaitingDelivery?.options?.deliverAs === 'nextTurn', 'zpeer_ask waiting feed must use nextTurn delivery and never create an extra steering/follow-up turn');
|
|
494
|
+
assert(JSON.stringify(toolResult?.content ?? '').includes('delivery-only: no reply received; not evidence the target is working or done'), 'zpeer_ask waiting result must not imply peer progress from delivery-only status');
|
|
491
495
|
assert(!feedMessages.some((item) => item.customType === 'zob-zpeer-event' && item.details?.source === 'agent-request' && item.details?.kind === 'attempt'), 'zpeer_ask async must not emit a pre-ACK attempt feed event');
|
|
492
496
|
assert(!containsRawBody(toolResult), 'zpeer_ask async tool result must not echo the full prompt/response body');
|
|
493
497
|
assert(appendEntries.some((item) => item.customType === 'zob-zpeer' && item.data?.schema === 'zob.zpeer-ask.v1' && item.data?.action === 'agent_request' && item.data?.mode === 'async' && item.data?.bodyStored === false), 'zpeer_ask must append hash-only visible command metadata');
|
|
@@ -538,6 +542,8 @@ async function main() {
|
|
|
538
542
|
const explicitReplyCountBefore = receivedResponses.length;
|
|
539
543
|
const explicitReplyResult = await zpeerReply.execute('tool-call-zpeer-reply', { msgId: explicitReplyMsgId, message: rawResponse }, undefined, undefined, { cwd: repoRoot });
|
|
540
544
|
assert(explicitReplyResult?.details?.status === 'response_sent' && explicitReplyResult?.details?.msgId === explicitReplyMsgId && explicitReplyResult?.details?.outputHash === hashing.sha256(rawResponse), 'zpeer_reply must send a msgId-bound response with outputHash metadata');
|
|
545
|
+
const explicitReplyFeedDelivery = feedDeliveries.find((item) => item.message.details?.kind === 'response_sent' && item.message.details?.msgId === explicitReplyMsgId);
|
|
546
|
+
assert(explicitReplyFeedDelivery?.options?.triggerTurn === false && explicitReplyFeedDelivery?.options?.deliverAs === 'nextTurn', 'zpeer_reply feed must use nextTurn delivery and never create an extra steering/follow-up turn');
|
|
541
547
|
assert(!toolState.zobLive.inboundByMsgId?.[explicitReplyMsgId] && toolState.zobLive.activeInboundMsgId === undefined && toolState.zobLive.inboundQueue.length === 0, 'zpeer_reply must clear the answered inbound msgId from receiver state');
|
|
542
548
|
assert(receivedResponses.length > explicitReplyCountBefore && receivedResponses.at(-1)?.replyToMsgId === explicitReplyMsgId && receivedResponses.at(-1)?.responseHash === hashing.sha256(rawResponse), 'zpeer_reply must deliver a local response envelope bound to replyToMsgId');
|
|
543
549
|
const wrongExplicitReply = await zpeerReply.execute('tool-call-zpeer-reply-wrong', { msgId: 'missing-msgid', message: rawResponse }, undefined, undefined, { cwd: repoRoot });
|
|
@@ -61,10 +61,11 @@ const zpeerAskMatches = toolsComs.match(/name: "zpeer_ask"/g) ?? [];
|
|
|
61
61
|
if (zpeerAskMatches.length !== 1) failures.push(`expected exactly one zpeer_ask tool, found ${zpeerAskMatches.length}`);
|
|
62
62
|
const zpeerReplyMatches = toolsComs.match(/name: "zpeer_reply"/g) ?? [];
|
|
63
63
|
if (zpeerReplyMatches.length !== 1) failures.push(`expected exactly one zpeer_reply tool, found ${zpeerReplyMatches.length}`);
|
|
64
|
-
for (const needle of ['parameters: ZpeerAskParams', 'sendZpeerPrompt(ctx.cwd', 'mode = params.mode ?? "async"', 'requireResponse = params.requireResponse === true', 'pendingReplies.wait(msgId, timeoutMs, { requireResponse })', 'maxReinjects', 'zpeerAskGuardBlock', '
|
|
64
|
+
for (const needle of ['parameters: ZpeerAskParams', 'sendZpeerPrompt(ctx.cwd', 'mode = params.mode ?? "async"', 'requireResponse = params.requireResponse === true', 'pendingReplies.wait(msgId, timeoutMs, { requireResponse })', 'maxReinjects', 'zpeerAskGuardBlock', 'clampRate(process.env.ZOB_ZPEER_ASK_RATE_LIMIT_PER_MINUTE, 50)', 'clampRate(process.env.ZOB_ZPEER_URGENT_RATE_LIMIT_PER_MINUTE, 10)', 'clampRate(process.env.ZOB_ZPEER_FORCE_RATE_LIMIT_PER_MINUTE, 3)', 'normalizeZpeerInterrupt', 'force interrupt requires reason', 'max ${ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE} agent-initiated ZPeer asks per 60s window', 'idle/passive wait: no follow-up turn queued', 'delivery-only: no reply received; not evidence the target is working or done', 'promptGuidelines:', 'requireResponse=true and mode=\\"await\\" or mode=\\"long\\"', 'customType: "zob-zpeer-event"', 'source: "agent-request"', 'action: "agent_request"', 'feedbackEmittedTerminal', 'reasonInputHash', 'interruptReasonHash', 'buildProjectTransposerFallbackDelivery(ctx.cwd)', 'PROJECT_TRANSPOSER_ZPEER_FALLBACK_HOOK', 'fallbackDelivery: result.fallback_delivery', 'bestEffort: result.best_effort', 'bodyStored: false', 'promptBodiesStored: false', 'outputBodiesStored: false']) {
|
|
65
65
|
if (!toolsComs.includes(needle)) failures.push(`zpeer_ask tool missing ${needle}`);
|
|
66
66
|
}
|
|
67
|
-
|
|
67
|
+
if (!toolsComs.includes('}, { triggerTurn: false, deliverAs: "nextTurn" }')) failures.push('zpeer tool feed cards must use nextTurn delivery so metadata-only UI cannot create steering turns');
|
|
68
|
+
for (const needle of ['parameters: ZpeerReplyParams', 'buildZobLiveResponseEnvelope', 'sendZobLocalEnvelope(replyEndpoint', 'replyToMsgId: inbound.envelope.msgId', 'action: "reply"', 'action: "reply_blocked"', 'status: "response_sent"', 'late reply after the requireResponse watchdog expired']) {
|
|
68
69
|
if (!toolsComs.includes(needle)) failures.push(`zpeer_reply tool missing ${needle}`);
|
|
69
70
|
}
|
|
70
71
|
if (toolsComs.includes('emitZpeerAskEvent({ kind: "attempt", status: "agent-request"')) failures.push('zpeer_ask async must not emit pre-ACK attempt feed noise');
|
|
@@ -178,6 +179,7 @@ for (const needle of ['customType: "zob-zpeer-event"', 'if (sendMode.mode !== "a
|
|
|
178
179
|
if (!command.includes(needle)) failures.push(`zpeer command missing UX event/status support ${needle}`);
|
|
179
180
|
}
|
|
180
181
|
if (!command.includes('const eventFromAlias = peerAliasInRoom(state.zobLive.peerCard, eventRoomId)') || !command.includes('peerAliasInRoom(state.zobLive.peerCard, resultRoomId)')) failures.push('/zpeer in <room> events must use room-scoped sender alias');
|
|
182
|
+
if (!command.includes('}, { triggerTurn: false, deliverAs: "nextTurn" });')) failures.push('/zpeer feed cards must use nextTurn delivery so metadata-only UI cannot create steering turns');
|
|
181
183
|
for (const forbidden of ['transientPrompt:', 'transientResponse:', 'prompt:', 'output:', 'content:', 'message:', 'text:', 'diff:', 'patch:']) {
|
|
182
184
|
const appendBlocks = [...command.matchAll(/appendEntry\("zob-zpeer",\s*\{[\s\S]*?\}\);/g)].map((m) => m[0]);
|
|
183
185
|
if (appendBlocks.some((block) => block.includes(forbidden))) failures.push(`zpeer appendEntry contains forbidden key ${forbidden}`);
|
|
@@ -215,6 +217,9 @@ for (const needle of ['readZpeerNewCarryoverProfile(repoRoot)', 'const carryover
|
|
|
215
217
|
}
|
|
216
218
|
if (!events.includes('event.source === "extension" && !event.text.trim()') || !events.includes('action: "handled" as const')) failures.push('runtime must handle empty extension follow-ups without continuing the agent');
|
|
217
219
|
if (!events.includes('ZPeer async reply received from @') || !events.includes('{ triggerTurn: true, deliverAs: "followUp" }')) failures.push('runtime must resume an idle agent with a follow-up when an async ZPeer reply arrives');
|
|
220
|
+
const eventZpeerFeedFragments = events.split('customType: "zob-zpeer-event"').slice(1);
|
|
221
|
+
if (eventZpeerFeedFragments.length === 0 || eventZpeerFeedFragments.some((fragment) => !fragment.slice(0, 800).includes('}, { triggerTurn: false, deliverAs: "nextTurn" });'))) failures.push('every runtime ZPeer feed card must use nextTurn delivery so it cannot race ahead of actionable prompt/response bodies');
|
|
222
|
+
if (!events.includes('status=waiting is delivery-only, not evidence that the peer is working or done')) failures.push('runtime awareness must prevent interpreting waiting as peer progress/completion evidence');
|
|
218
223
|
for (const needle of ['forceAbortAllowedForCurrentState', 'interruptStatus = "force_blocked"', 'interruptStatus = "force_downgraded"', 'ctx.abort()', 'const deliverAs = priority === "normal" ? "followUp" : "steer"', 'inboundByMsgId', 'activeInboundMsgId', 'state.zobLive.inboundByMsgId && !activeInbound', 'scheduleZpeerRequiredResponseWatchdog', 'required_response_reinject', 'required_response_expired', 'Original transient message:', 'zpeer_required_response_expired', 'pendingReplies.expire', 'forceAbortRepeated: false', 'replyToMsgId !== envelope.msgId', 'wrong or missing replyToMsgId']) {
|
|
219
224
|
if (!events.includes(needle)) failures.push(`runtime missing urgent/force/msgId-safe inbound support ${needle}`);
|
|
220
225
|
}
|