zigmini 1.0.0-beta.2 → 1.1.0-beta.1
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/README.ko.md +36 -3
- package/README.md +36 -3
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +120 -11
- package/docs/manual.md +123 -12
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +230 -31
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/profile.ts +13 -5
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/usage.ts +18 -7
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +35 -1
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +16 -10
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
package/src/hub.ts
CHANGED
|
@@ -4,25 +4,40 @@ import type { SttProvider } from "./adapters/voice/stt.ts";
|
|
|
4
4
|
import { blockingCompaction, compactionOutcome, OmoCommandError, type CompactionFailureKind, type OmoEvent, type OmoStatus } from "./omo/client.ts";
|
|
5
5
|
import type { OmoRpc } from "./omo/generations.ts";
|
|
6
6
|
import { type ActionAnswerData, type ConversationActivity, type ConversationInfo, type Envelope, type MessageReference, PROTOCOL_VERSION, type Source, type Trust } from "./protocol.ts";
|
|
7
|
-
import { readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import { isDeepStrictEqual } from "node:util";
|
|
8
9
|
import { mediaFile } from "./media.ts";
|
|
9
|
-
import type
|
|
10
|
+
import { releasedWorkQuestion, type Decision, type DecisionSubmission, type LedgerEntry, type LedgerEntryInput, type LedgerState, type MainInput, type MainInputSource, type Store, type StoredEvent } from "./store.ts";
|
|
10
11
|
import { type TranscriptLog, toRecord } from "./transcript.ts";
|
|
11
12
|
import { MaintenanceError, nativeMaintenanceBlocker } from "./maintenance.ts";
|
|
12
13
|
import { isVersionCommand, VERSION_COMMAND } from "./version.ts";
|
|
13
14
|
import { isUsageCommand, USAGE_COMMAND } from "./usage.ts";
|
|
14
15
|
import { COMPACT_COMMAND, compactInstructions } from "./compact.ts";
|
|
15
16
|
import { MessageReferenceError, referencedInput, resolveMessageReferences } from "./message-references.ts";
|
|
17
|
+
import type { AttachmentService } from "./attachments/service.ts";
|
|
18
|
+
import { AttachmentStoreError, type AttachmentMetadata } from "./attachments/types.ts";
|
|
16
19
|
import { readProfile } from "./profile.ts";
|
|
17
|
-
import type
|
|
20
|
+
import { countFindings, type MessageGuard, type SecurityChecker, type SecurityFinding } from "./security-check.ts";
|
|
18
21
|
import { SecurityRuntimeError, type SecurityReceipt } from "./security-allow.ts";
|
|
19
22
|
import { nativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
20
23
|
import { notificationHistory } from "./notification-history.ts";
|
|
21
24
|
import { messageTimestamp, nativeTurnStarted, nativeTurnStats } from "./turn-stats.ts";
|
|
22
25
|
import { join } from "node:path";
|
|
23
26
|
import { mergeAgentDefinition, AgentDefinitionError, type MergedAgentDefinition } from "./agent/definition.ts";
|
|
27
|
+
import { legacyToolScopes } from "./agent/definition.ts";
|
|
28
|
+
import type { AgentToolMigrationCard } from "./agent/tool-permissions.ts";
|
|
24
29
|
import { AgentFileStore } from "./agent/files.ts";
|
|
30
|
+
import type { CollaborationLifecycle } from "./agent/collaboration-session.ts";
|
|
31
|
+
import { CollaborationStoreError, collaborationNever } from "./agent/collaboration-types.ts";
|
|
25
32
|
import { type Language, say, serverLanguage, type Text, textIn } from "./server-text.ts";
|
|
33
|
+
import { createSecretKey, randomBytes, randomUUID, type KeyObject } from "node:crypto";
|
|
34
|
+
import { ManagedCallError, verifyManagedCall, type ManagedCallBinding, type ManagedCallBindingResolver, type ManagedCallIdentity, type VerifiedManagedCall } from "./agent/managed-call.ts";
|
|
35
|
+
import type { ManagedSession } from "./attachments/managed-binding.ts";
|
|
36
|
+
import { AgentToolPermissionError, type AgentAccessView, type AgentToolGrantRow, type AgentToolRequestInput, type AgentToolRequestRecord } from "./agent/tool-permissions.ts";
|
|
37
|
+
import { agentToolActionId, agentToolOperationId, engineCommands, grantedOperations, pushSessionPolicy } from "./agent/tool-delivery.ts";
|
|
38
|
+
import { ASIDE_OPERATIONS } from "./agent/tool-catalog.ts";
|
|
39
|
+
import { maintenanceAuth } from "./maintenance.ts";
|
|
40
|
+
import { testCrashPoint } from "./test-crash.ts";
|
|
26
41
|
|
|
27
42
|
class AgentSessionError extends Error {
|
|
28
43
|
constructor() {
|
|
@@ -79,6 +94,10 @@ export interface HubOptions {
|
|
|
79
94
|
usageReport?: () => Promise<string>;
|
|
80
95
|
/** Improvement 165: the security check of user text; a finding keeps the text from omo and the store. */
|
|
81
96
|
guard?: Pick<MessageGuard, "check" | "hold" | "existing">;
|
|
97
|
+
/** Selected host documents; absent when this gateway has no attachment service wired. */
|
|
98
|
+
documents?: AttachmentService;
|
|
99
|
+
/** 294: agent session binding for /internal/agent-tools/*; `ready` runs after a bind so held grants are delivered. */
|
|
100
|
+
agentTools?: { readonly bindings: AgentToolBindings; readonly ready?: (convId: string) => void };
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
/** Anthropic rejects images over 5 MB; bigger attachments reach the agent as a file path only. */
|
|
@@ -232,6 +251,8 @@ function describeBlockers(blockers: readonly RotationBlocker[]): string {
|
|
|
232
251
|
const HANDOFF_MAX_CHARS = 8000;
|
|
233
252
|
const HANDOFF_LINE_CHARS = 600;
|
|
234
253
|
const CONTEXT_OVERFLOW = /prompt is too long|context overflow/i;
|
|
254
|
+
/** ensureSession/prompt refusals that mean "the session is not ready now", not a dispatch failure. */
|
|
255
|
+
const DISPATCH_NOT_READY = new Set(["omo_not_ready", "stale_open_session", "session_rotating", "omo_owner_not_ready", "agent_busy"]);
|
|
235
256
|
const COMPACTION_REQUIRED_MESSAGE: Text = {
|
|
236
257
|
en: "The engine did not receive this message because the conversation summary did not finish. The message stays in the history and will not be sent again automatically. Fix the cause of the summary failure or press 'New session'.",
|
|
237
258
|
ko: "대화 요약이 끝나지 않아 엔진이 이 메시지를 받지 않았습니다. 메시지는 기록에 남아 있고 자동으로 다시 보내지 않습니다. 요약 실패 원인을 해결하거나 '새 세션'을 눌러 주세요.",
|
|
@@ -281,8 +302,33 @@ export const HUB_TEXT = {
|
|
|
281
302
|
recoveredMany: { en: " Filled in {count} missed answers.", ko: " 놓친 답변 {count}개를 채웠습니다." },
|
|
282
303
|
multiSelect: { en: " (multiple choices allowed)", ko: " (여러 개 선택 가능)" },
|
|
283
304
|
questionUnreachable: { en: "The question window did not reach the web. Press Stop, then answer in the chat.", ko: "질문 창이 웹에 도착하지 않았습니다. 중단 버튼을 누른 뒤 채팅으로 답해 주세요." },
|
|
305
|
+
collaborationRequestSent: { en: "Sent a collaboration request", ko: "협업 요청을 보냈습니다" },
|
|
306
|
+
collaborationRequestReceived: { en: "Received a collaboration request", ko: "협업 요청을 받았습니다" },
|
|
307
|
+
collaborationResultSent: { en: "Sent the collaboration result", ko: "협업 결과를 보냈습니다" },
|
|
308
|
+
collaborationResultReceived: { en: "Received the collaboration result", ko: "협업 결과를 받았습니다" },
|
|
309
|
+
collaborationEndedWithoutResult: { en: "The collaboration request ended without a result", ko: "협업 요청이 결과 없이 끝났습니다" },
|
|
284
310
|
} as const satisfies Record<string, Text>;
|
|
285
311
|
|
|
312
|
+
/**
|
|
313
|
+
* C-4 fallback for apps that do not know `message.collaboration`: one `notice {kind:"collaboration",requestId,direction,message}`
|
|
314
|
+
* in the same conversation as each stored collaboration message (the web skips it next to its card). A result-less final
|
|
315
|
+
* reply (`agent.collaboration.final_reply`) is the requester's reply too and names its final state and reason. Null for anything else.
|
|
316
|
+
*/
|
|
317
|
+
export function collaborationNotice(event: Pick<StoredEvent, "type" | "data">): { readonly kind: "collaboration"; readonly requestId: string; readonly direction: "request" | "reply"; readonly message: string } | null {
|
|
318
|
+
const data = event.data;
|
|
319
|
+
if (event.type === "agent.collaboration.final_reply" && typeof data === "object" && data !== null && "requestId" in data && typeof data.requestId === "string" &&
|
|
320
|
+
"state" in data && typeof data.state === "string" && "reason" in data && typeof data.reason === "string") {
|
|
321
|
+
return { kind: "collaboration", requestId: data.requestId, direction: "reply", message: `${say(HUB_TEXT.collaborationEndedWithoutResult)}: ${data.state} (${data.reason})` };
|
|
322
|
+
}
|
|
323
|
+
if (event.type !== "message.collaboration" || typeof data !== "object" || data === null || !("requestId" in data) || typeof data.requestId !== "string" ||
|
|
324
|
+
!("direction" in data) || (data.direction !== "request" && data.direction !== "reply")) return null;
|
|
325
|
+
const outgoing = "outgoing" in data && data.outgoing === true;
|
|
326
|
+
const label = data.direction === "request" ? (outgoing ? HUB_TEXT.collaborationRequestSent : HUB_TEXT.collaborationRequestReceived)
|
|
327
|
+
: (outgoing ? HUB_TEXT.collaborationResultSent : HUB_TEXT.collaborationResultReceived);
|
|
328
|
+
const text = "text" in data && typeof data.text === "string" ? data.text.trim() : "";
|
|
329
|
+
return { kind: "collaboration", requestId: data.requestId, direction: data.direction, message: text ? `${say(label)}: ${text}` : `${say(label)}.` };
|
|
330
|
+
}
|
|
331
|
+
|
|
286
332
|
/** Why a session was replaced. The user notice uses the UI language; the engine context keeps the Korean text. */
|
|
287
333
|
const ROTATION_REASON = {
|
|
288
334
|
size: { en: "the session file reached {mb} MB", ko: "세션 파일이 {mb}MB에 닿음" },
|
|
@@ -343,6 +389,10 @@ interface ConvRuntime {
|
|
|
343
389
|
atomicQueuedResume: boolean;
|
|
344
390
|
/** `open_session` state.sessionId: the session file's own identity, which survives reconnects. */
|
|
345
391
|
durableId: string | null;
|
|
392
|
+
/** Current attachment-read binding of this native session. */
|
|
393
|
+
documentIncarnation?: string;
|
|
394
|
+
/** The host advertises document input and this session's gateway extension registered the resolver. */
|
|
395
|
+
documentResolver?: boolean;
|
|
346
396
|
waking: Promise<void> | null;
|
|
347
397
|
wake: WorkWake | null;
|
|
348
398
|
notified: Set<string>;
|
|
@@ -430,6 +480,12 @@ export const MAIN_QUESTION_RELEASED =
|
|
|
430
480
|
"question open in the user's inbox and returned at once. The user has not answered yet. The answer arrives later as a " +
|
|
431
481
|
"ZGW_DECISION_COMMAND user message with the question and the response. Until then, do not take the action that depends on " +
|
|
432
482
|
"this answer and do not ask it again; handle every other message as usual.";
|
|
483
|
+
/** Bug 236: the tool result a work gets for its required question when a newer instruction was queued behind it. */
|
|
484
|
+
export const WORK_QUESTION_RELEASED =
|
|
485
|
+
"ZGW_QUESTION_OPEN This note is from the gateway, not the user's answer. A newer instruction arrived while this work waited " +
|
|
486
|
+
"on this question, so the gateway ended the wait and kept the question open in the user's inbox. The user has not answered " +
|
|
487
|
+
"yet. The answer arrives later as a ZGW_WORK_COMMAND with the question and the response. Until then, do not take the action " +
|
|
488
|
+
"that depends on this answer and do not ask it again; handle the newer instruction and every other message as usual.";
|
|
433
489
|
const PERMISSION_PREFIX = "Permission required:";
|
|
434
490
|
const AUTO_CONTINUE_LIMIT = 2;
|
|
435
491
|
const CONTINUE_PROMPT =
|
|
@@ -471,6 +527,11 @@ export function modelOptions(models: OmoModel[]): ModelChoice[] {
|
|
|
471
527
|
}
|
|
472
528
|
|
|
473
529
|
export class Hub {
|
|
530
|
+
private collaboration: CollaborationLifecycle | undefined;
|
|
531
|
+
private readonly collaborationFlights = new Map<string, Promise<void>>();
|
|
532
|
+
private readonly settling = new Map<string, number>();
|
|
533
|
+
private readonly collaborationGeneration = crypto.randomUUID();
|
|
534
|
+
private readonly collaborationIncarnations = new Map<string, string>();
|
|
474
535
|
private readonly runtime = new Map<string, ConvRuntime>();
|
|
475
536
|
private readonly deletingAgents = new Set<string>();
|
|
476
537
|
private readonly sessionToConv = new Map<string, string>();
|
|
@@ -514,12 +575,266 @@ export class Hub {
|
|
|
514
575
|
) {
|
|
515
576
|
if (!store.getConversation("main")) store.createConversation("main", "main");
|
|
516
577
|
this.maintenanceId = store.getSetting("maintenance.owner") || null;
|
|
578
|
+
store.onCollaborationEvents = () => this.publishCollaborationEvents();
|
|
517
579
|
}
|
|
518
580
|
|
|
519
581
|
attachOmo(omo: OmoRpc): void {
|
|
520
582
|
this.omo = omo;
|
|
521
583
|
}
|
|
522
584
|
|
|
585
|
+
attachCollaboration(lifecycle: CollaborationLifecycle): void { this.collaboration = lifecycle; }
|
|
586
|
+
|
|
587
|
+
checkCollaborationText(text: string): void {
|
|
588
|
+
// Artifact/peer review is not an owner message closure. T7 owns that purpose-specific ingress.
|
|
589
|
+
if (this.options.guard?.check([text])) throw new CollaborationStoreError("collaboration_security_review_required");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** The finding kinds and counts of the texts a collaboration read would release; empty when the check passes or no guard is set. */
|
|
593
|
+
collaborationFindings(texts: readonly string[]): SecurityFinding[] {
|
|
594
|
+
return countFindings(this.options.guard?.check(texts) ?? []);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** A security hold's decision changed outside the DecisionService (created, reviewed, settled, resumed): publish its decision.updated. */
|
|
598
|
+
refreshDecisions(): void {
|
|
599
|
+
this.decisions?.refresh();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Owner allow and the reader's settle both call this; whichever comes second queues the one reviewed read. */
|
|
603
|
+
resumeArtifactReview(holdId: string): void {
|
|
604
|
+
try {
|
|
605
|
+
const hold = this.store.artifactSecurityHold(holdId), lifecycle = this.collaboration;
|
|
606
|
+
if (!lifecycle || !hold || hold.state !== "allowed" || hold.answerSubmissionId === null || !hold.continuation.settledReceipt ||
|
|
607
|
+
hold.continuation.resumeSubmissionId !== null) return;
|
|
608
|
+
this.store.resumeArtifactRead({ holdId, expectedRevision: hold.revision, resumeSubmissionId: hold.answerSubmissionId, readOperationId: hold.readOperationId,
|
|
609
|
+
scopeDigest: hold.scopeDigest, ownerPrincipalRef: hold.ownerPrincipalRef }, this.store.artifactReaderBinding(hold.readOperationId), lifecycle.artifactRead(hold));
|
|
610
|
+
void this.dispatchCollaboration(hold.readScope.granteeConv);
|
|
611
|
+
} finally {
|
|
612
|
+
// The settle before this call and the resume itself both moved the hold's decision revision.
|
|
613
|
+
this.decisions?.refresh();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Internal 294 consumer. This is not an owner answer endpoint or an ordinary permission router. */
|
|
618
|
+
acceptCollaborationPermission(outcome: import("./agent/collaboration-types.ts").CollaborationPermissionOutcome) {
|
|
619
|
+
if (!this.collaboration) throw new CollaborationStoreError("authority_adapter_required", 503);
|
|
620
|
+
const activation = this.collaboration.permission(outcome);
|
|
621
|
+
void this.dispatchCollaboration(activation.targetConv);
|
|
622
|
+
return { activationId: activation.id, inputId: activation.inputId, state: activation.state };
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** A lease of an already-open native session; HTTP callers cannot open or select another conversation. */
|
|
626
|
+
collaborationSession(convId: string) {
|
|
627
|
+
const conv = this.store.listConversations(false).find(row => row.id === convId), rt = this.runtime.get(convId);
|
|
628
|
+
const incarnation = this.collaborationIncarnations.get(convId);
|
|
629
|
+
if (!conv || !conv.threadId || !rt?.sessionId || !rt.durableId || !incarnation ||
|
|
630
|
+
this.omoStatus !== "ready" || rt.rotating || this.maintenanceId || this.deletingAgents.has(convId)) return null;
|
|
631
|
+
const sessionId = rt.sessionId, durableId = rt.durableId, generation = this.generation;
|
|
632
|
+
const ownerInputRevision = this.store.lastSeq(convId, "message.user");
|
|
633
|
+
const current = () => this.omoStatus === "ready" && generation === this.generation && !rt.rotating &&
|
|
634
|
+
!this.maintenanceId && rt.sessionId === sessionId && rt.durableId === durableId &&
|
|
635
|
+
this.collaborationIncarnations.get(convId) === incarnation &&
|
|
636
|
+
this.sessionToConv.get(sessionId) === convId && this.store.getConversation(convId)?.threadId === conv.threadId &&
|
|
637
|
+
this.store.listConversations(false).some(row => row.id === convId && row.createdAt === conv.createdAt && row.agentId === conv.agentId) &&
|
|
638
|
+
!this.deletingAgents.has(convId) && this.store.lastSeq(convId, "message.user") === ownerInputRevision;
|
|
639
|
+
return { sessionId, durableId, sessionPath: conv.threadId, agentId: conv.agentId ?? null, incarnation,
|
|
640
|
+
generation: `${this.collaborationGeneration}:${generation}`, ownerInputRevision,
|
|
641
|
+
convEpoch: Date.parse(conv.createdAt), current,
|
|
642
|
+
rpc: (type: string, params: Record<string, unknown> = {}): Promise<unknown> =>
|
|
643
|
+
this.requireOmo().command(type, { ...params, sessionId }) };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* A peer's tool call can reach HTTP before this Hub handles the input's user message_end frame. The caller has already
|
|
648
|
+
* authenticated the native consumption; record it from the same ledger evidence so the Store never refuses the call.
|
|
649
|
+
*/
|
|
650
|
+
async recordCollaborationConsumption(convId: string, inputId: string): Promise<void> {
|
|
651
|
+
const sessionId = this.rt(convId).sessionId, generation = this.generation;
|
|
652
|
+
const input = this.store.mainTurn(convId)?.inputs.find(row => (row.data as MainInput).input_id === inputId);
|
|
653
|
+
if (!sessionId || !input || this.store.mainInputConsumed(input)) return;
|
|
654
|
+
const data: MainInput = JSON.parse(JSON.stringify(input.data));
|
|
655
|
+
if (data.origin !== "agent_collaboration" && data.origin !== "agent_tool_permission") return;
|
|
656
|
+
const ledger = await this.currentLedger(convId, sessionId, generation);
|
|
657
|
+
const entry = ledger && this.ledgerInputEntry(ledger, data);
|
|
658
|
+
if (!ledger || !entry || generation !== this.generation) return;
|
|
659
|
+
const consumed = this.store.consumeMainInput(input, { entry, durableId: ledger.durableId });
|
|
660
|
+
if (consumed) this.publishStored(consumed);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Publish only the committed queued records; T6 owns subsequent dispatch and consumption. */
|
|
664
|
+
collaborationQueued(requestId: string, cursors: ReadonlyMap<string, number>): void {
|
|
665
|
+
for (const [conv, cursor] of cursors) for (const event of this.store.eventsAfter(conv, cursor)) {
|
|
666
|
+
if (event.type === "message.collaboration" || event.type === "agent.collaboration.status") {
|
|
667
|
+
const data = event.data;
|
|
668
|
+
if (typeof data === "object" && data !== null && "requestId" in data && data.requestId === requestId) {
|
|
669
|
+
this.publishStored(event);
|
|
670
|
+
this.noticeCollaboration(event);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// The same admission may have moved the parent request (waiting_peer); its status goes out with the rest.
|
|
675
|
+
this.publishCollaborationEvents();
|
|
676
|
+
const request = this.store.getAgentRequest(requestId);
|
|
677
|
+
if (request) void this.dispatchCollaboration(request.targetConv);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* F3 D1: every stored collaboration event the Store queued since the last call reaches the open pages now, on the same
|
|
682
|
+
* frames their history replay uses (the requester's status changes and a result's two reply messages), so a page never
|
|
683
|
+
* has to reload to see a card finish, stop or wait for the owner. The receiving conversation stores no status of its
|
|
684
|
+
* own (its listing carries the state), so its open page gets the requester's status as a live frame that is not stored.
|
|
685
|
+
* Called at the Hub's own points and by the Store one microtask after any other transaction that queued events.
|
|
686
|
+
*/
|
|
687
|
+
publishCollaborationEvents(): void {
|
|
688
|
+
for (const event of this.store.takeCollaborationEvents()) {
|
|
689
|
+
this.publishStored(event);
|
|
690
|
+
if (event.type !== "agent.collaboration.status") continue;
|
|
691
|
+
const data = event.data;
|
|
692
|
+
const request = typeof data === "object" && data !== null && "requestId" in data && typeof data.requestId === "string" ? this.store.getAgentRequest(data.requestId) : null;
|
|
693
|
+
if (request && request.targetConv !== event.conv) this.emit(request.targetConv, "agent.collaboration.status", data, false);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Asks the conversation's current extension to drain and seal the outputs listed by its frozen result execute. */
|
|
698
|
+
async collaborationSnapshot(convId: string, call: { readonly requestId: string; readonly requestRevision: number; readonly operationKey: string;
|
|
699
|
+
readonly bindingDigest: string; readonly paths: readonly string[] }): Promise<
|
|
700
|
+
{ readonly ok: true; readonly files: readonly { readonly sealedName: string; readonly sha256: string; readonly bytes: number; readonly mediaType: string; readonly name: string }[] } |
|
|
701
|
+
{ readonly ok: false; readonly error: string }> {
|
|
702
|
+
const sessionId = this.rt(convId).sessionId;
|
|
703
|
+
if (!sessionId || this.omoStatus !== "ready") throw new CollaborationStoreError("collaboration_session_unavailable", 503);
|
|
704
|
+
return this.requireOmo().command("extension_request", { sessionId, name: "zgw.agent_snapshot", data: { ...call, paths: [...call.paths] } });
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Cooperative interrupt for executions whose request is cancel_requested, sent only while that exact activation is still
|
|
709
|
+
* the conversation's current binding and run. An owner turn or a later activation is never aborted.
|
|
710
|
+
*/
|
|
711
|
+
async interruptCancelledCollaboration(): Promise<void> {
|
|
712
|
+
for (const { conv, activationId } of this.store.cancelRequestedExecutions()) {
|
|
713
|
+
const rt = this.rt(conv), sessionId = rt.sessionId;
|
|
714
|
+
if (!sessionId || !rt.running || this.omoStatus !== "ready" || this.store.agentBinding(conv)?.activationId !== activationId) continue;
|
|
715
|
+
await this.requireOmo().command("abort", { sessionId });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** A settled execution commits its explicit result once (waking the requester) or records result_missing. */
|
|
720
|
+
private finalizeCollaboration(activationId: string): void {
|
|
721
|
+
const lifecycle = this.collaboration;
|
|
722
|
+
if (!lifecycle) return;
|
|
723
|
+
const finished = this.store.finalizeAgentActivation(activationId, request => lifecycle.gate(request));
|
|
724
|
+
if (finished.result) {
|
|
725
|
+
testCrashPoint("collaboration.result.committed");
|
|
726
|
+
// The committed status and reply messages reach the open pages before their fallback notices.
|
|
727
|
+
this.publishCollaborationEvents();
|
|
728
|
+
// The result's two reply messages were just committed as the last collaboration message of each side.
|
|
729
|
+
const request = this.store.getAgentRequest(finished.result.requestId);
|
|
730
|
+
for (const conv of request ? [request.requesterConv, request.targetConv] : []) {
|
|
731
|
+
const reply = this.store.getEvent(this.store.lastSeq(conv, "message.collaboration"));
|
|
732
|
+
const data = reply?.data;
|
|
733
|
+
if (reply && typeof data === "object" && data !== null && "resultId" in data && data.resultId === finished.result.id) this.noticeCollaboration(reply);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
for (const decisionId of this.store.collaborationPermissionDecisions(activationId)) this.continueCollaborationPermission(decisionId);
|
|
737
|
+
this.wakeCollaborationReplies();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Sent once, when the collaboration message itself is first published, so a replay never repeats it. */
|
|
741
|
+
private noticeCollaboration(event: StoredEvent): void {
|
|
742
|
+
const notice = collaborationNotice(event);
|
|
743
|
+
if (notice) this.emit(event.conv, "notice", notice);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A reply queued by a result or by a final state without one wakes its requester; dispatch keeps owner turns first. */
|
|
747
|
+
wakeCollaborationReplies(): void {
|
|
748
|
+
// The pages get a cancel or a result-less end at once, whatever the engine's state (a closed Store queues nothing).
|
|
749
|
+
this.publishCollaborationEvents();
|
|
750
|
+
// A disconnected Hub dispatches nothing; its Store may already be closed under a late continuation.
|
|
751
|
+
if (this.omoStatus !== "ready") return;
|
|
752
|
+
for (const reply of this.store.takeFinalReplies()) this.noticeCollaboration(reply);
|
|
753
|
+
for (const conv of this.store.queuedAgentReplyConvs()) void this.dispatchCollaboration(conv);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Only Hub opens the existing conversation. A queued peer never steers an active owner. */
|
|
757
|
+
dispatchCollaboration(convId: string): Promise<void> {
|
|
758
|
+
const active = this.collaborationFlights.get(convId);
|
|
759
|
+
if (active) return active;
|
|
760
|
+
const lifecycle = this.collaboration, rt = this.rt(convId);
|
|
761
|
+
const available = () => this.omoStatus === "ready" && !this.maintenanceId && !rt.rotating &&
|
|
762
|
+
!this.closeUncertain(convId) && !rt.running && rt.admitting === 0 && !rt.waking && !rt.wake &&
|
|
763
|
+
!this.settling.get(convId) && !this.store.mainTurn(convId) && this.store.pendingMainUsers(convId).length === 0;
|
|
764
|
+
// Liveness first: a prompt finishing after its engine disconnected (and its Store closed) must not read the Store.
|
|
765
|
+
if (!lifecycle || !available()) return Promise.resolve();
|
|
766
|
+
const first = this.store.nextAgentInput(convId);
|
|
767
|
+
const firstRead = this.store.nextArtifactReadInput(convId);
|
|
768
|
+
if (!first && !firstRead) return Promise.resolve();
|
|
769
|
+
const generation = this.generation, run = rt.run;
|
|
770
|
+
const flight = (async () => {
|
|
771
|
+
const conv = this.store.getConversation(convId);
|
|
772
|
+
if (!conv) return;
|
|
773
|
+
let readable = firstRead;
|
|
774
|
+
if (readable) {
|
|
775
|
+
try { lifecycle.artifactRead(readable.hold); }
|
|
776
|
+
catch (error) {
|
|
777
|
+
if (!(error instanceof CollaborationStoreError) || error.status !== 503) throw error;
|
|
778
|
+
this.emit(convId, "agent.collaboration.blocked", { code: error.code, holdId: readable.hold.id });
|
|
779
|
+
readable = null;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
if (!first && !readable) return;
|
|
783
|
+
const { sessionId } = await this.ensureSession(conv);
|
|
784
|
+
const state = await this.requireOmo().command<{ isStreaming?: boolean; pendingMessageCount?: number; isCompacting?: boolean;
|
|
785
|
+
isBashRunning?: boolean }>("get_state", { sessionId });
|
|
786
|
+
const runtime = await this.requireOmo().command<{ ownedInFlight?: number }>("extension_request",
|
|
787
|
+
{ sessionId, name: "zgw.agent_collaboration_runtime" });
|
|
788
|
+
if (generation !== this.generation || rt.run !== run || rt.sessionId !== sessionId || !available() ||
|
|
789
|
+
state.isStreaming !== false || state.pendingMessageCount !== 0 || state.isCompacting || state.isBashRunning ||
|
|
790
|
+
runtime.ownedInFlight !== 0) return;
|
|
791
|
+
const next = this.store.nextAgentInput(convId);
|
|
792
|
+
const read = readable && this.store.nextArtifactReadInput(convId);
|
|
793
|
+
if (read && (!next || read.seq < next.activation.inputSeq)) {
|
|
794
|
+
const body = `ZGW_COLLABORATION_INPUT ${JSON.stringify({ origin: "collaboration_artifact_read",
|
|
795
|
+
holdId: read.hold.id, readOperationId: read.hold.readOperationId, continuationId: read.hold.continuation.id,
|
|
796
|
+
reviewedScopeDigest: read.hold.scopeDigest, read: read.hold.readArguments, trust: "delegated" })}`;
|
|
797
|
+
await this.prompt(convId, body, { artifactRead: read.hold });
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (!next) return;
|
|
801
|
+
const event = this.store.getEvent(next.activation.inputSeq);
|
|
802
|
+
const intent: import("./agent/collaboration-types.ts").CollaborationIntent = JSON.parse(JSON.stringify(event?.data));
|
|
803
|
+
let payload: unknown;
|
|
804
|
+
switch (intent.origin) {
|
|
805
|
+
case "agent_collaboration": payload = this.store.getEvent(intent.messageSeq)?.data; break;
|
|
806
|
+
case "agent_tool_permission": payload = intent.outcome; break;
|
|
807
|
+
case "collaboration_artifact_read": case "collaboration_stage": throw new CollaborationStoreError("input_source_mismatch");
|
|
808
|
+
default: return collaborationNever(intent);
|
|
809
|
+
}
|
|
810
|
+
// A single JSON value keeps slash/control/envelope-looking peer bytes literal.
|
|
811
|
+
const body = `ZGW_COLLABORATION_INPUT ${JSON.stringify({ origin: intent.origin, requestId: next.request.id,
|
|
812
|
+
rootId: next.request.rootId, activationId: next.activation.id, trust: "delegated", payload })}`;
|
|
813
|
+
await this.prompt(convId, body, { collaboration: next });
|
|
814
|
+
})().catch(error => {
|
|
815
|
+
// Every caller drops this promise (`void`), so nothing may escape it. A flight whose engine generation ended, or
|
|
816
|
+
// whose session was not ready, is stale rather than failed: its input was never claimed and stays queued for the
|
|
817
|
+
// next ready/settle dispatch.
|
|
818
|
+
if (generation !== this.generation || this.omoStatus !== "ready" || error instanceof MaintenanceError ||
|
|
819
|
+
(error instanceof Error && DISPATCH_NOT_READY.has(error.message))) {
|
|
820
|
+
console.info(`collaboration dispatch (${convId}) not ready: ${errorMessage(error)}`);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
// Any other failure is logged and reported on the conversation. An input that never crossed the prompt stays
|
|
824
|
+
// queued (the next dispatch retries it); prompt() itself already recorded the delivery state of one that did.
|
|
825
|
+
console.error(`collaboration dispatch failed (${convId}): ${errorMessage(error)}`);
|
|
826
|
+
this.emit(convId, "agent.collaboration.blocked", { code: error instanceof CollaborationStoreError ? error.code : "dispatch_failed" });
|
|
827
|
+
}).finally(() => {
|
|
828
|
+
if (this.collaborationFlights.get(convId) !== flight) return;
|
|
829
|
+
this.collaborationFlights.delete(convId);
|
|
830
|
+
// A settle before the prompt ACK must not lose the wake for the next FIFO item.
|
|
831
|
+
if (available() && (this.store.nextAgentInput(convId)?.activation.id !== first?.activation.id ||
|
|
832
|
+
this.store.nextArtifactReadInput(convId)?.hold.id !== firstRead?.hold.id)) void this.dispatchCollaboration(convId);
|
|
833
|
+
}).catch(error => console.error(`collaboration dispatch wake failed (${convId}): ${errorMessage(error)}`));
|
|
834
|
+
this.collaborationFlights.set(convId, flight);
|
|
835
|
+
return flight;
|
|
836
|
+
}
|
|
837
|
+
|
|
523
838
|
/** Explicit mode activation snapshots every existing main source before admitting new occurrences. */
|
|
524
839
|
async notificationBaseline(): Promise<void> {
|
|
525
840
|
for (const conv of this.store.listConversations()) {
|
|
@@ -539,6 +854,27 @@ export class Hub {
|
|
|
539
854
|
|
|
540
855
|
attachDecisions(decisions: DecisionService): void {
|
|
541
856
|
this.decisions = decisions;
|
|
857
|
+
decisions.useToolAnswered(decisionId => this.continueCollaborationPermission(decisionId));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Q-07: the owner's stored answer to a 294 card asked inside a collaboration execution continues that request once.
|
|
862
|
+
* Called after the answer commits and after the asking execution settles; the dispatcher dedupes the exact outcome, so
|
|
863
|
+
* whichever comes second is the one that continues. A refusal is reported on the request's conversation.
|
|
864
|
+
*/
|
|
865
|
+
continueCollaborationPermission(decisionId: string): void {
|
|
866
|
+
const outcome = this.store.collaborationPermissionOutcome(decisionId);
|
|
867
|
+
if (!outcome || !this.collaboration) return;
|
|
868
|
+
const blocked = this.store.getAgentActivation(outcome.blockedActivationId);
|
|
869
|
+
if (!blocked?.quiesced) return;
|
|
870
|
+
try {
|
|
871
|
+
this.acceptCollaborationPermission(outcome);
|
|
872
|
+
// The answer also released the requests below the held one (as a question answer does).
|
|
873
|
+
for (const conv of this.store.agentDecisionDependents(decisionId)) void this.dispatchCollaboration(conv);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
if (!(error instanceof CollaborationStoreError)) throw error;
|
|
876
|
+
this.emit(blocked.targetConv, "agent.collaboration.blocked", { code: error.code, decisionId });
|
|
877
|
+
}
|
|
542
878
|
}
|
|
543
879
|
|
|
544
880
|
decisionLiveBinding(decision: Decision): DecisionSubmission["liveBinding"] {
|
|
@@ -572,7 +908,7 @@ export class Hub {
|
|
|
572
908
|
async dispatchDecisionMain(input: StoredEvent): Promise<void> {
|
|
573
909
|
const data = input.data as { text: string };
|
|
574
910
|
this.publishStored(input);
|
|
575
|
-
await this.prompt(input.conv, data.text,
|
|
911
|
+
await this.prompt(input.conv, data.text, { messageSeq: input.seq, admitted: true });
|
|
576
912
|
}
|
|
577
913
|
|
|
578
914
|
/**
|
|
@@ -583,9 +919,12 @@ export class Hub {
|
|
|
583
919
|
const conv = this.store.getConversation(convId);
|
|
584
920
|
if (!conv || this.omoStatus !== "ready") return;
|
|
585
921
|
const generation = this.generation;
|
|
922
|
+
const started = conv.threadId;
|
|
586
923
|
const { sessionId } = await this.ensureSession(conv);
|
|
587
924
|
const ledger = await this.currentLedger(convId, sessionId, generation, 30_000);
|
|
588
|
-
|
|
925
|
+
// Evidence read for the settled session never judges the decisions of a session the conversation rotated to meanwhile.
|
|
926
|
+
if (!ledger || (started && ledger.sessionPath !== started) ||
|
|
927
|
+
ledger.sessionPath !== this.store.getConversation(convId)?.threadId) return;
|
|
589
928
|
const path = ledger.sessionPath;
|
|
590
929
|
const owner = { conv: convId, scope: this.conversationScope(convId), sessionPath: path };
|
|
591
930
|
const userMessages = this.store.ledgerUserTexts(path);
|
|
@@ -625,6 +964,10 @@ export class Hub {
|
|
|
625
964
|
return this.omoStatus === "ready" && sessionId !== undefined && this.sessionToConv.has(sessionId);
|
|
626
965
|
}
|
|
627
966
|
|
|
967
|
+
hasSession(conv: string): boolean {
|
|
968
|
+
return this.runtime.get(conv)?.sessionId != null;
|
|
969
|
+
}
|
|
970
|
+
|
|
628
971
|
/** Execution indicator, not authority to replace the native runtime. */
|
|
629
972
|
busy(): boolean {
|
|
630
973
|
for (const rt of this.runtime.values()) if (rt.admitting > 0 || rt.running || rt.rotating || rt.opening || rt.waking || rt.wake) return true;
|
|
@@ -750,12 +1093,13 @@ export class Hub {
|
|
|
750
1093
|
}
|
|
751
1094
|
|
|
752
1095
|
/**
|
|
753
|
-
* Main questions kept open without their native wait
|
|
754
|
-
* notifications) come from the stored decisions,
|
|
1096
|
+
* Main questions kept open without their native wait, and work questions released for a newer instruction (bug 236),
|
|
1097
|
+
* still wait for the user: their cards (and so the device question notifications) come from the stored decisions,
|
|
1098
|
+
* which outlive a gateway restart.
|
|
755
1099
|
*/
|
|
756
1100
|
private releasedMainCards(): (QuestionCard & { decisionId: string })[] {
|
|
757
|
-
return this.store.allDecisions().filter((d) => d.
|
|
758
|
-
d.bindingState === "closed" && d.reason === "main_released"
|
|
1101
|
+
return this.store.allDecisions().filter((d) => d.method === "question" && d.status === "pending" && d.sourceSessionPath !== null &&
|
|
1102
|
+
((d.workId === null && d.bindingState === "closed" && d.reason === "main_released") || releasedWorkQuestion(d))).map((d) => {
|
|
759
1103
|
const payload = d.payload as { title?: unknown; questions?: unknown };
|
|
760
1104
|
return {
|
|
761
1105
|
actionId: d.legacyActionId ?? scopedActionId(d.scope, d.sourceRequestId), scope: d.scope, conv: d.ownerConv,
|
|
@@ -787,7 +1131,8 @@ export class Hub {
|
|
|
787
1131
|
*/
|
|
788
1132
|
history(conv: string, query: HistoryQuery = {}): HistoryPage {
|
|
789
1133
|
const limit = Math.min(Math.max(Math.trunc(query.limit ?? HISTORY_PAGE), 1), HISTORY_MAX_PAGE);
|
|
790
|
-
const toEnvelope = (
|
|
1134
|
+
const toEnvelope = (stored: StoredEvent): Envelope => {
|
|
1135
|
+
const e = this.withAttachmentState(stored);
|
|
791
1136
|
const action = e.data;
|
|
792
1137
|
if (e.type !== "action.request" || typeof action !== "object" || action === null || Array.isArray(action) ||
|
|
793
1138
|
!("actionId" in action) || typeof action.actionId !== "string" || "decisionId" in action) {
|
|
@@ -912,7 +1257,9 @@ export class Hub {
|
|
|
912
1257
|
rt.sessionId = null;
|
|
913
1258
|
}
|
|
914
1259
|
const entry = trash();
|
|
915
|
-
this.store.setConversationHidden(convId, true);
|
|
1260
|
+
const hide = () => this.store.setConversationHidden(convId, true);
|
|
1261
|
+
if (this.options.documents) this.options.documents.retireRecipient(convId, conv.agentId, hide);
|
|
1262
|
+
else hide();
|
|
916
1263
|
return entry;
|
|
917
1264
|
} finally {
|
|
918
1265
|
this.deletingAgents.delete(convId);
|
|
@@ -925,6 +1272,8 @@ export class Hub {
|
|
|
925
1272
|
// A connection change is an observation too: a ledger read issued before it is never shared after it.
|
|
926
1273
|
this.eventCount += 1;
|
|
927
1274
|
if (status !== "ready") {
|
|
1275
|
+
this.collaboration?.retire();
|
|
1276
|
+
this.collaborationIncarnations.clear();
|
|
928
1277
|
this.generation += 1;
|
|
929
1278
|
for (const recovery of this.closeRecoveries.values()) {
|
|
930
1279
|
if (recovery.timer !== undefined) this.clearCloseTimer(recovery.timer);
|
|
@@ -954,6 +1303,8 @@ export class Hub {
|
|
|
954
1303
|
}
|
|
955
1304
|
}
|
|
956
1305
|
}
|
|
1306
|
+
// A fresh connection (gateway start or reconnect) owns no earlier native run: deliveries left mid-boundary are uncertain.
|
|
1307
|
+
if (status === "ready") this.store.markUncertainAgentDeliveries();
|
|
957
1308
|
this.publishAllActivities();
|
|
958
1309
|
this.emit("*", "omo.status", { status, detail: detail ?? null }, false);
|
|
959
1310
|
return status === "ready" && !this.maintenanceId
|
|
@@ -983,9 +1334,9 @@ export class Hub {
|
|
|
983
1334
|
if (pendingUsers.length > 0) {
|
|
984
1335
|
await this.ensureSession(current);
|
|
985
1336
|
for (const user of pendingUsers) {
|
|
986
|
-
const data = user.data as { text: string; images?: string[]; references?: MessageReference[] };
|
|
987
|
-
const input = referencedInput(data.text, data.images ?? [], data.references);
|
|
988
|
-
await this.prompt(conv.id, input.text, input.images, user.seq, true);
|
|
1337
|
+
const data = user.data as { text: string; images?: string[]; references?: MessageReference[]; attachments?: AttachmentMetadata[]; documentFallback?: boolean };
|
|
1338
|
+
const input = referencedInput(data.documentFallback ? "" : data.text, data.images ?? [], data.references, data.attachments);
|
|
1339
|
+
await this.prompt(conv.id, input.text, { images: input.images, messageSeq: user.seq, admitted: true, documents: data.attachments });
|
|
989
1340
|
}
|
|
990
1341
|
} else if (this.hasUnfinishedTurn(conv.id)) await this.recoverConversation(current);
|
|
991
1342
|
else if (this.openQuestionCards(conv.id, this.conversationScope(conv.id)).size > 0) await this.ensureSession(current);
|
|
@@ -1004,6 +1355,7 @@ export class Hub {
|
|
|
1004
1355
|
}
|
|
1005
1356
|
// Opening main comes first. A notification ACK or worker reconciliation cannot hold readiness.
|
|
1006
1357
|
void this.notifyWorkUpdates(conv.id);
|
|
1358
|
+
void this.dispatchCollaboration(conv.id);
|
|
1007
1359
|
} catch (error) {
|
|
1008
1360
|
if (generation === this.generation && activityRecovery === this.activityRecoveryGeneration) {
|
|
1009
1361
|
this.activityRecoveryFailedGeneration = activityRecovery;
|
|
@@ -1015,7 +1367,7 @@ export class Hub {
|
|
|
1015
1367
|
}
|
|
1016
1368
|
|
|
1017
1369
|
/** `images` are names of files already uploaded to the media folder; unknown names are dropped. */
|
|
1018
|
-
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string): Promise<void> {
|
|
1370
|
+
async sendText(convId: string, text: string, src: Source, trust: Trust, images: string[] = [], referenceIds?: unknown, checked = false, submissionId?: string, principal: string | null = null): Promise<void> {
|
|
1019
1371
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1020
1372
|
this.requireVisibleAgent(convId);
|
|
1021
1373
|
if (submissionId !== undefined && !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)) throw new SecurityRuntimeError("held_conflict");
|
|
@@ -1027,6 +1379,9 @@ export class Hub {
|
|
|
1027
1379
|
const body = text.trim();
|
|
1028
1380
|
const source = structuredClone(src);
|
|
1029
1381
|
const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
|
|
1382
|
+
if (references.some((reference) => reference.attachments?.length)) {
|
|
1383
|
+
return this.sendDocuments({ convId, text, src: source, trust, images, referenceIds, attachments: [], submissionId, principal });
|
|
1384
|
+
}
|
|
1030
1385
|
if (this.options.versionReport && images.length === 0 && references.length === 0 && isVersionCommand(body)) {
|
|
1031
1386
|
this.emit(convId, "notice", { kind: "version", level: "info", message: await this.options.versionReport() });
|
|
1032
1387
|
return;
|
|
@@ -1069,7 +1424,8 @@ export class Hub {
|
|
|
1069
1424
|
message: say(HUB_TEXT.closeChecking) });
|
|
1070
1425
|
return;
|
|
1071
1426
|
}
|
|
1072
|
-
await this.prompt(convId, prompt.text, prompt.images, input.seq, true,
|
|
1427
|
+
await this.prompt(convId, prompt.text, { images: prompt.images, messageSeq: input.seq, admitted: true,
|
|
1428
|
+
attachmentSnapshot, protectedPayload: receipt !== undefined });
|
|
1073
1429
|
};
|
|
1074
1430
|
// The approved closure owns the inspected references, body and image bytes. Never re-resolve reference ids.
|
|
1075
1431
|
const found = checked ? null : this.options.guard?.check([prompt.text]);
|
|
@@ -1080,6 +1436,108 @@ export class Hub {
|
|
|
1080
1436
|
await send();
|
|
1081
1437
|
}
|
|
1082
1438
|
|
|
1439
|
+
/**
|
|
1440
|
+
* Documents enter through one Store transaction after the exact manifest is inspected. This path has no command
|
|
1441
|
+
* shortcuts, and a held send keeps its fixed plan: release fails if the prepared content changed meanwhile.
|
|
1442
|
+
*/
|
|
1443
|
+
async sendDocuments(input: { convId: string; text: string; src: Source; trust: Trust; images: string[]; referenceIds: unknown; attachments: string[]; submissionId: string | undefined; principal: string | null; reply?: (frame: unknown) => void }): Promise<void> {
|
|
1444
|
+
const { convId, text, src, trust, images, referenceIds, attachments, submissionId, principal } = input;
|
|
1445
|
+
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
1446
|
+
this.requireVisibleAgent(convId);
|
|
1447
|
+
const documents = this.options.documents;
|
|
1448
|
+
if (!documents) throw new AttachmentStoreError("attachment_provider_invalid", 503);
|
|
1449
|
+
if (principal === null) throw new AttachmentStoreError("attachment_owner_inactive", 403);
|
|
1450
|
+
if (submissionId === undefined) throw new AttachmentStoreError("invalid_attachment_input", 400);
|
|
1451
|
+
const context = { key: JSON.stringify(["message", convId, submissionId]), submissionId,
|
|
1452
|
+
snapshot: structuredClone({ text, src, trust, images, referenceIds, attachments }) };
|
|
1453
|
+
// An approved hold is already an accepted submission: the plan below returns its receipt or refuses a changed payload.
|
|
1454
|
+
const held = this.options.guard?.existing(context);
|
|
1455
|
+
if (held && held.status !== "sent") return;
|
|
1456
|
+
const references = structuredClone(resolveMessageReferences(this.store, convId, referenceIds));
|
|
1457
|
+
const dir = this.options.media?.dir;
|
|
1458
|
+
const attached = dir ? [...new Set(images)].filter((n) => mediaFile(dir, n)) : [];
|
|
1459
|
+
const plan = await documents.plan({ convId, principal, text, images: attached, references, src: structuredClone(src), trust, submissionId, attachments });
|
|
1460
|
+
const imageSnapshot = this.attachments(attached);
|
|
1461
|
+
const send = async (receipt?: (ref: SecurityReceipt) => void) => {
|
|
1462
|
+
this.requireVisibleAgent(convId);
|
|
1463
|
+
const accepted = documents.commit(plan, receipt);
|
|
1464
|
+
const stored = this.store.getEvent(accepted.messageSeq);
|
|
1465
|
+
// Only the live frame names the submission: the stored message and its request digest keep their shape.
|
|
1466
|
+
const echoed = stored && { ...stored, data: { ...(this.withAttachmentState(stored).data as Record<string, unknown>), submissionId } };
|
|
1467
|
+
if (!accepted.fresh) {
|
|
1468
|
+
if (echoed) input.reply?.({ v: PROTOCOL_VERSION, id: this.nextId("e"), ts: echoed.ts, conv: echoed.conv, type: echoed.type, data: echoed.data, seq: echoed.seq });
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
if (echoed) this.publishStored(echoed);
|
|
1472
|
+
this.decisions?.refresh();
|
|
1473
|
+
const rotationDone = this.rt(convId).rotationDone;
|
|
1474
|
+
if (rotationDone) await rotationDone;
|
|
1475
|
+
if (this.closeUncertain(convId)) {
|
|
1476
|
+
this.scheduleCloseRecovery(convId);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
const prompt = referencedInput(plan.body, attached, references, accepted.attachments);
|
|
1480
|
+
await this.prompt(convId, prompt.text, { images: prompt.images, messageSeq: accepted.messageSeq, admitted: true,
|
|
1481
|
+
attachmentSnapshot: imageSnapshot, protectedPayload: receipt !== undefined, documents: accepted.attachments });
|
|
1482
|
+
};
|
|
1483
|
+
if (plan.findings) {
|
|
1484
|
+
this.options.guard?.hold(convId, "message", [...plan.findings], send, plan.body, context);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
await send();
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/**
|
|
1491
|
+
* The live session's provider, API and input types pick the representation; a client never supplies them. A native
|
|
1492
|
+
* route also needs a host that registered this gateway's resolver, otherwise support is unproven and pages are used.
|
|
1493
|
+
*/
|
|
1494
|
+
async documentRoute(convId: string): Promise<"native-document" | "pages" | "ocr"> {
|
|
1495
|
+
const conv = this.store.getConversation(convId);
|
|
1496
|
+
if (!conv) throw new Error(`unknown_conversation:${convId}`);
|
|
1497
|
+
const { sessionId } = await this.ensureSession(conv);
|
|
1498
|
+
const state = await this.requireOmo().command<{ model?: { input?: unknown; provider?: unknown; api?: unknown } | null }>("get_state", { sessionId });
|
|
1499
|
+
const input = state.model?.input;
|
|
1500
|
+
if (!Array.isArray(input)) throw new Error("model_capability_unknown");
|
|
1501
|
+
if (!input.includes("image")) return "ocr";
|
|
1502
|
+
const publicRoute = (state.model?.provider === "openai" && state.model?.api === "openai-responses") ||
|
|
1503
|
+
(state.model?.provider === "anthropic" && state.model?.api === "anthropic-messages");
|
|
1504
|
+
return publicRoute && this.rt(convId).documentResolver === true ? "native-document" : "pages";
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
emitNotice(convId: string, kind: string): void {
|
|
1508
|
+
this.emit(convId, "notice", { kind, level: "warning", message: kind });
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/** Rotates the read secret on every native open; a failed bind leaves only a refused tool. */
|
|
1512
|
+
private async bindDocuments(conv: ConversationInfo, sessionId: string, sessionPath: string, rt: ConvRuntime, generation: number): Promise<void> {
|
|
1513
|
+
const documents = this.options.documents;
|
|
1514
|
+
if (!documents || !rt.durableId) return;
|
|
1515
|
+
const issued = documents.bindings.bind({
|
|
1516
|
+
role: conv.agentId ? "agent" : "main", agentId: conv.agentId ?? null, convId: conv.id, convEpoch: 1, lifecycleEpoch: documents.lifecycleEpoch(conv.id),
|
|
1517
|
+
sessionPath, durableSessionId: rt.durableId,
|
|
1518
|
+
}, () => generation === this.generation && rt.sessionId === sessionId);
|
|
1519
|
+
rt.documentIncarnation = issued.identity.sessionIncarnation;
|
|
1520
|
+
const last = this.store.getEvent(this.store.lastSeq(conv.id, "main.input"));
|
|
1521
|
+
const data = last?.data as { input_id?: unknown; session_path?: unknown } | undefined;
|
|
1522
|
+
const inputId = typeof data?.input_id === "string" && data.session_path === sessionPath ? data.input_id : null;
|
|
1523
|
+
if (last && inputId) documents.bindings.setInput(conv.id, issued.identity.sessionIncarnation, inputId, last.seq);
|
|
1524
|
+
try {
|
|
1525
|
+
const bound = await this.requireOmo().command<{ documentResolver?: unknown } | null>("extension_request", { sessionId, name: "zgw.attachment_bind", data: { secret: issued.secret, identity: issued.identity, inputId } });
|
|
1526
|
+
const protocol = this.requireOmo().protocolInfo as { capabilities?: unknown } | null;
|
|
1527
|
+
rt.documentResolver = bound?.documentResolver === true && Array.isArray(protocol?.capabilities) && protocol.capabilities.includes("document_content_v1");
|
|
1528
|
+
} catch (error) {
|
|
1529
|
+
documents.bindings.retire(conv.id);
|
|
1530
|
+
rt.documentResolver = false;
|
|
1531
|
+
console.error(`attachment read bind failed (${conv.id}): ${errorMessage(error)}`);
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/** The extension signs with this id, so it must learn it before the native turn can call the tool. */
|
|
1536
|
+
private async announceDocumentInput(sessionId: string, incarnation: string, inputId: string): Promise<void> {
|
|
1537
|
+
try { await this.requireOmo().command("extension_request", { sessionId, name: "zgw.attachment_input", data: { sessionIncarnation: incarnation, inputId } }); }
|
|
1538
|
+
catch (error) { console.error(`attachment read input failed: ${errorMessage(error)}`); }
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1083
1541
|
/** Reserve gateway report admission before its HTTP body is parsed. The source
|
|
1084
1542
|
* is not known yet, so this short request conservatively holds every conversation.
|
|
1085
1543
|
* It never waits for a worker run or interrupts native work.
|
|
@@ -1115,6 +1573,8 @@ export class Hub {
|
|
|
1115
1573
|
notifyWorkUpdates(convId: string): Promise<void> {
|
|
1116
1574
|
if (this.maintenanceId) return Promise.resolve();
|
|
1117
1575
|
const rt = this.rt(convId);
|
|
1576
|
+
const source = this.store.mainTurn(convId)?.source;
|
|
1577
|
+
if (source && source.origin !== "user" && source.origin !== "work_update") return Promise.resolve();
|
|
1118
1578
|
if (this.omoStatus !== "ready") return Promise.resolve();
|
|
1119
1579
|
if (rt.rotating || this.closeUncertain(convId)) return Promise.resolve();
|
|
1120
1580
|
if (rt.waking) return rt.waking;
|
|
@@ -1139,6 +1599,7 @@ export class Hub {
|
|
|
1139
1599
|
}
|
|
1140
1600
|
|
|
1141
1601
|
private async injectWorkUpdates(convId: string, generation: number): Promise<void> {
|
|
1602
|
+
this.collaboration?.retire(convId);
|
|
1142
1603
|
const conv = this.store.getConversation(convId);
|
|
1143
1604
|
if (!conv) return;
|
|
1144
1605
|
const { sessionId } = await this.ensureSession(conv);
|
|
@@ -1223,6 +1684,56 @@ export class Hub {
|
|
|
1223
1684
|
const generation = this.generation;
|
|
1224
1685
|
const run = rt.run;
|
|
1225
1686
|
const wake = rt.wake;
|
|
1687
|
+
const authored = this.store.mainTurn(conv)?.inputs.filter(input => {
|
|
1688
|
+
const data: MainInput = JSON.parse(JSON.stringify(input.data));
|
|
1689
|
+
return data.origin !== "user" && data.origin !== "work_update";
|
|
1690
|
+
}) ?? [];
|
|
1691
|
+
if (authored.length) {
|
|
1692
|
+
const [ledger, state, runtime] = await Promise.all([
|
|
1693
|
+
this.currentLedger(conv, sessionId, generation),
|
|
1694
|
+
this.requireOmo().command<{ isStreaming?: boolean; pendingMessageCount?: number }>("get_state", { sessionId }),
|
|
1695
|
+
this.requireOmo().command<{ ownedInFlight?: number }>("extension_request", { sessionId, name: "zgw.agent_collaboration_runtime" }),
|
|
1696
|
+
]);
|
|
1697
|
+
if (!ledger || generation !== this.generation || rt.sessionId !== sessionId || rt.run !== run ||
|
|
1698
|
+
state.isStreaming !== false || state.pendingMessageCount !== 0 || runtime.ownedInFlight !== 0) return;
|
|
1699
|
+
for (const input of authored) {
|
|
1700
|
+
const data: MainInput = JSON.parse(JSON.stringify(input.data));
|
|
1701
|
+
const entry = this.ledgerInputEntry(ledger, data);
|
|
1702
|
+
const after = entry && this.ledgerAfter(ledger, entry);
|
|
1703
|
+
// The cooperative cancel interrupt ends its run with an aborted final message; that run is over, so it settles too.
|
|
1704
|
+
// A stopped reply to a root requester executes no request, so nothing else could ever settle it.
|
|
1705
|
+
const cancelInterrupted = !!after && turnAborted(after) && (data.origin === "agent_collaboration" || data.origin === "agent_tool_permission") &&
|
|
1706
|
+
(this.store.cancelRequestedExecutions().some(execution => execution.activationId === data.input_ref.activation_id) ||
|
|
1707
|
+
this.store.agentReplyWithoutExecution(data.input_ref.activation_id));
|
|
1708
|
+
if (!entry || !after || !(turnFinished(after) || cancelInterrupted)) return;
|
|
1709
|
+
const consumed = this.store.consumeMainInput(input, { entry, durableId: ledger.durableId });
|
|
1710
|
+
if (consumed) this.publishStored(consumed);
|
|
1711
|
+
switch (data.origin) {
|
|
1712
|
+
case "agent_collaboration": case "agent_tool_permission": {
|
|
1713
|
+
const activation = this.store.getAgentActivation(data.input_ref.activation_id);
|
|
1714
|
+
if (activation?.binding && activation.state === "consumed" && !activation.quiesced) {
|
|
1715
|
+
const evidence = { settlementId: `${ledger.durableId}:${ledger.leafId}`, ownedInFlight: runtime.ownedInFlight ?? -1, reconciled: true };
|
|
1716
|
+
this.store.settleAgentActivation({ activationId: activation.id, expectedRevision: activation.revision, ...evidence }, activation.binding);
|
|
1717
|
+
// A read held in this activation becomes resumable only at its exact settle.
|
|
1718
|
+
for (const hold of this.store.unsettledArtifactHolds(activation.id)) {
|
|
1719
|
+
this.store.settleArtifactRead(hold.id, evidence, activation.binding);
|
|
1720
|
+
this.resumeArtifactReview(hold.id);
|
|
1721
|
+
}
|
|
1722
|
+
this.finalizeCollaboration(activation.id);
|
|
1723
|
+
}
|
|
1724
|
+
break;
|
|
1725
|
+
}
|
|
1726
|
+
case "collaboration_artifact_read": {
|
|
1727
|
+
const binding = this.store.agentBinding(conv);
|
|
1728
|
+
if (binding && binding.inputId === data.input_id) this.store.settleArtifactRead(data.input_ref.holdId,
|
|
1729
|
+
{ settlementId: `${ledger.durableId}:${ledger.leafId}`, ownedInFlight: runtime.ownedInFlight, reconciled: true }, binding);
|
|
1730
|
+
break;
|
|
1731
|
+
}
|
|
1732
|
+
case "user": case "work_update": break;
|
|
1733
|
+
default: return collaborationNever(data);
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1226
1737
|
try {
|
|
1227
1738
|
if (this.store.mainConsumedSource(conv)?.origin !== "user" &&
|
|
1228
1739
|
wake && !this.store.mainInputConsumed(wake.input)) {
|
|
@@ -1367,6 +1878,17 @@ export class Hub {
|
|
|
1367
1878
|
return { images, note: `[첨부 이미지 ${names.length}장: ${names.map((n) => `${where}/${n}`).join(", ")}]` };
|
|
1368
1879
|
}
|
|
1369
1880
|
|
|
1881
|
+
/**
|
|
1882
|
+
* Opens an agent conversation's next session now, after a rotation that asked for it, so a saved definition is applied
|
|
1883
|
+
* without a user message (ensureSession records agent.applied). Main never opens here: its rotation behaviour is unchanged.
|
|
1884
|
+
*/
|
|
1885
|
+
async openAgentSession(convId: string): Promise<boolean> {
|
|
1886
|
+
const conv = this.store.getConversation(convId);
|
|
1887
|
+
if (!conv || conv.agentId === null) return false;
|
|
1888
|
+
await this.ensureSession(conv);
|
|
1889
|
+
return true;
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1370
1892
|
/**
|
|
1371
1893
|
* Moves the conversation to a fresh omo session file. The chat history stays; the old session file stays on disk,
|
|
1372
1894
|
* and the next prompt carries a short note with the recent exchange (the handoff) so the new session has context.
|
|
@@ -1922,13 +2444,20 @@ export class Hub {
|
|
|
1922
2444
|
}
|
|
1923
2445
|
|
|
1924
2446
|
/** Sends input once. Native same-session compaction owns overflow recovery, never gateway replay. */
|
|
1925
|
-
private async prompt(convId: string, body: string,
|
|
2447
|
+
private async prompt(convId: string, body: string, options: { readonly images?: string[]; readonly messageSeq?: number;
|
|
2448
|
+
readonly admitted?: boolean; readonly attachmentSnapshot?: ReturnType<Hub["attachments"]>; readonly protectedPayload?: boolean;
|
|
2449
|
+
readonly documents?: readonly AttachmentMetadata[];
|
|
2450
|
+
} & ({ readonly collaboration?: NonNullable<ReturnType<Store["nextAgentInput"]>>; readonly artifactRead?: never } |
|
|
2451
|
+
{ readonly artifactRead: import("./security-allow.ts").ArtifactSecurityHold; readonly collaboration?: never }) = {}): Promise<void> {
|
|
2452
|
+
const { images = [], messageSeq, admitted = false, attachmentSnapshot, protectedPayload = false, documents = [], collaboration, artifactRead } = options;
|
|
2453
|
+
const authored = collaboration !== undefined || artifactRead !== undefined;
|
|
1926
2454
|
this.requireVisibleAgent(convId);
|
|
1927
2455
|
const conv = this.store.getConversation(convId);
|
|
1928
2456
|
if (!conv) return;
|
|
1929
2457
|
const rt = this.rt(convId);
|
|
1930
2458
|
if (rt.rotating) return; // Accepted user intent remains definitely unsent until rotation finishes.
|
|
1931
2459
|
const generation = this.generation;
|
|
2460
|
+
const run = rt.run;
|
|
1932
2461
|
let input: StoredEvent | null = null;
|
|
1933
2462
|
let nativeCommand = false;
|
|
1934
2463
|
let sessionId: string | null = null;
|
|
@@ -1941,39 +2470,96 @@ export class Hub {
|
|
|
1941
2470
|
try {
|
|
1942
2471
|
({ sessionId } = await this.ensureSession(conv));
|
|
1943
2472
|
if (generation !== this.generation || this.omoStatus !== "ready") return;
|
|
2473
|
+
this.toolMigrationBeforeTurn(convId);
|
|
1944
2474
|
const reference = messageSeq ?? this.store.lastSeq(convId, "message.user");
|
|
1945
2475
|
// sendText and reconnect recovery can share admission; only one may cross the write boundary.
|
|
1946
2476
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
1947
2477
|
const commandName = body.slice(1).split(" ", 1)[0];
|
|
1948
|
-
nativeCommand = (body === "." && images.length === 0) ||
|
|
1949
|
-
(body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName));
|
|
2478
|
+
nativeCommand = !authored && ((body === "." && images.length === 0) ||
|
|
2479
|
+
(body.startsWith("/") && (await this.commands(convId)).some((command) => command.name === commandName)));
|
|
1950
2480
|
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId) return;
|
|
1951
2481
|
if (admitted && this.store.mainUserDispatched(convId, reference)) return;
|
|
1952
|
-
const handoff = nativeCommand ? null : this.store.getSessionMeta(convId).handoff;
|
|
2482
|
+
const handoff = nativeCommand || authored ? null : this.store.getSessionMeta(convId).handoff;
|
|
1953
2483
|
const attached = attachmentSnapshot ?? this.attachments(images);
|
|
1954
2484
|
const text = [body, attached.note].filter(Boolean).join("\n\n");
|
|
1955
2485
|
const context = handoff ? `${handoff}\n\nZGW_WORK_STATE ${JSON.stringify(this.workSummary(convId))}` : null;
|
|
1956
|
-
const inputId = crypto.randomUUID();
|
|
2486
|
+
const inputId = collaboration?.activation.inputId ?? crypto.randomUUID();
|
|
1957
2487
|
const message = context ? `${context}\n\n[이번 메시지]\n${text}` : text;
|
|
1958
2488
|
const wire = nativeCommand || rt.clientInputIdentity ? message : `ZGW_MAIN_INPUT ${JSON.stringify({ input_id: inputId, message_seq: reference })}\n${message}`;
|
|
1959
2489
|
const params: Record<string, unknown> = {
|
|
1960
2490
|
sessionId, message: wire,
|
|
1961
2491
|
streamingBehavior: "steer",
|
|
1962
|
-
sessionTitlePrompt: body,
|
|
2492
|
+
...(!authored ? { sessionTitlePrompt: body } : {}),
|
|
1963
2493
|
...(rt.clientInputIdentity ? { clientInputId: inputId } : {}),
|
|
1964
2494
|
};
|
|
1965
2495
|
if (attached.images.length) params.images = attached.images;
|
|
1966
|
-
input
|
|
2496
|
+
// Mac documents and their read binding belong to the owner's own input only; authored peer inputs never carry them.
|
|
2497
|
+
const incarnation = !authored && this.options.documents ? rt.documentIncarnation : undefined;
|
|
2498
|
+
if (!authored) {
|
|
2499
|
+
const native = documents.filter((item) => item.readMode === "native-document");
|
|
2500
|
+
if (native.length && this.options.documents && rt.clientInputIdentity) {
|
|
2501
|
+
try { Object.assign(params, this.options.documents.dispatchDocuments(convId, native)); }
|
|
2502
|
+
catch (error) { if (!(error instanceof AttachmentStoreError)) throw error; }
|
|
2503
|
+
}
|
|
2504
|
+
if (incarnation && sessionId) await this.announceDocumentInput(sessionId, incarnation, inputId);
|
|
2505
|
+
}
|
|
2506
|
+
if (authored) {
|
|
2507
|
+
const lifecycle = this.collaboration;
|
|
2508
|
+
if (!lifecycle) throw new CollaborationStoreError("collaboration_session_unavailable", 503);
|
|
2509
|
+
this.checkCollaborationText(body);
|
|
2510
|
+
const previousBinding = this.store.agentBinding(convId);
|
|
2511
|
+
if (!rt.clientInputIdentity) throw new CollaborationStoreError("client_input_identity_unavailable", 503);
|
|
2512
|
+
const context = collaboration ? { activationId: collaboration.activation.id,
|
|
2513
|
+
requestId: collaboration.activation.direction === "reply" ? collaboration.request.parentId : collaboration.request.id,
|
|
2514
|
+
rootId: collaboration.request.rootId } : { activationId: null, requestId: null, rootId: null };
|
|
2515
|
+
await lifecycle.bind(convId, inputId, { ...context,
|
|
2516
|
+
...(artifactRead ? { read: { continuationId: artifactRead.continuation.id, arguments: artifactRead.readArguments } } : {}),
|
|
2517
|
+
claim: binding => {
|
|
2518
|
+
if (generation !== this.generation || this.omoStatus !== "ready" || rt.sessionId !== sessionId ||
|
|
2519
|
+
rt.running || rt.run !== run || rt.admitting !== 1 || rt.rotating || this.maintenanceId) {
|
|
2520
|
+
throw new CollaborationStoreError("conversation_busy");
|
|
2521
|
+
}
|
|
2522
|
+
if (collaboration) {
|
|
2523
|
+
const { activation, request } = collaboration;
|
|
2524
|
+
input = this.store.admitAgentInput({ activationId: activation.id, activationRevision: activation.revision,
|
|
2525
|
+
requestRevision: request.revision, previousBinding, wireMessage: wire, nativeIdentity: rt.clientInputIdentity },
|
|
2526
|
+
binding, lifecycle.gate(request));
|
|
2527
|
+
} else if (artifactRead && previousBinding) {
|
|
2528
|
+
input = this.store.admitArtifactRead({ holdId: artifactRead.id, expectedRevision: artifactRead.revision,
|
|
2529
|
+
previousBinding, wireMessage: wire }, binding, lifecycle.artifactRead(artifactRead));
|
|
2530
|
+
} else throw new CollaborationStoreError("artifact_resume_conflict");
|
|
2531
|
+
this.publishStored(input);
|
|
2532
|
+
} });
|
|
2533
|
+
} else input = this.emit(convId, "main.input", {
|
|
1967
2534
|
origin: "user", input_ref: { message_seq: reference },
|
|
1968
2535
|
session_path: this.store.getConversation(convId)?.threadId, handoff,
|
|
1969
2536
|
...(!nativeCommand || rt.clientInputIdentity ? {
|
|
1970
2537
|
input_id: inputId, wire_message: wire, native_identity: rt.clientInputIdentity,
|
|
1971
2538
|
} : {}),
|
|
1972
2539
|
});
|
|
2540
|
+
if (incarnation && input) this.options.documents?.bindings.setInput(convId, incarnation, inputId, input.seq);
|
|
1973
2541
|
if (admitted) this.store.appendEvent(convId, "main.user.dispatched", { message_seq: reference, input_seq: input?.seq });
|
|
1974
|
-
|
|
2542
|
+
if (!authored) {
|
|
2543
|
+
if (!nativeCommand && rt.clientInputIdentity) {
|
|
2544
|
+
// The owner's own message never depends on collaboration capability: a refused bind only leaves the tools unbound.
|
|
2545
|
+
try { await this.collaboration?.bind(convId, inputId); }
|
|
2546
|
+
catch (error) {
|
|
2547
|
+
if (!(error instanceof CollaborationStoreError) && !(error instanceof OmoCommandError)) throw error;
|
|
2548
|
+
this.collaboration?.retire(convId);
|
|
2549
|
+
this.emit(convId, "agent.collaboration.blocked", { code: error instanceof CollaborationStoreError ? error.code : "collaboration_native_unavailable" });
|
|
2550
|
+
}
|
|
2551
|
+
} else this.collaboration?.retire(convId);
|
|
2552
|
+
}
|
|
2553
|
+
const sent = this.requireOmo().command<{ disposition?: string }>("prompt", params);
|
|
2554
|
+
if (collaboration) testCrashPoint("collaboration.input.rpc-written");
|
|
2555
|
+
const receipt = await sent;
|
|
1975
2556
|
if (input && generation === this.generation && this.omoStatus === "ready" && rt.sessionId === sessionId) {
|
|
1976
2557
|
this.store.ackMainInput(input);
|
|
2558
|
+
if (collaboration) {
|
|
2559
|
+
const activation = this.store.getAgentActivation(collaboration.activation.id);
|
|
2560
|
+
if (activation?.binding) this.store.recordAgentActivationEvidence({ activationId: activation.id,
|
|
2561
|
+
expectedRevision: activation.revision, state: "acknowledged" }, activation.binding);
|
|
2562
|
+
}
|
|
1977
2563
|
if (nativeCommand && receipt?.disposition === "handled") this.emit(convId, "main.input.handled", { input_seq: input.seq });
|
|
1978
2564
|
if (!rt.running && receipt?.disposition === "queued") void this.reconcileMainUsers(convId, sessionId, generation);
|
|
1979
2565
|
}
|
|
@@ -1989,11 +2575,35 @@ export class Hub {
|
|
|
1989
2575
|
if (refused && input) this.emit(convId, "main.input.handled", { input_seq: input.seq, reason: "prompt_rejected" });
|
|
1990
2576
|
if (protectedPayload) throw new SecurityRuntimeError("dispatch_unknown");
|
|
1991
2577
|
if (retired) return;
|
|
2578
|
+
if (collaboration) {
|
|
2579
|
+
const activation = this.store.getAgentActivation(collaboration.activation.id);
|
|
2580
|
+
if (error instanceof CollaborationStoreError && ["target_forbidden", "lifecycle_conflict", "agent_access_blocked",
|
|
2581
|
+
"authority_epoch_mismatch", "collaboration_security_review_required", "version_conflict"].includes(error.code)) {
|
|
2582
|
+
this.store.rejectQueuedAgentInput(collaboration.activation.id, error.code);
|
|
2583
|
+
this.wakeCollaborationReplies();
|
|
2584
|
+
}
|
|
2585
|
+
if (activation?.binding && activation.state !== "queued") this.store.recordAgentActivationEvidence({
|
|
2586
|
+
activationId: activation.id, expectedRevision: activation.revision, state: "unknown" }, activation.binding);
|
|
2587
|
+
if (error instanceof CollaborationStoreError) this.emit(convId, "agent.collaboration.blocked", { code: error.code });
|
|
2588
|
+
else this.emit(convId, "agent.collaboration.blocked", { code: "dispatch_unknown" });
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
if (artifactRead) {
|
|
2592
|
+
const hold = this.store.artifactSecurityHold(artifactRead.id);
|
|
2593
|
+
if (hold?.continuation.state === "dispatched" && hold.continuation.targetBinding) this.store.recordArtifactDelivery({
|
|
2594
|
+
holdId: hold.id, dispatchReceiptId: hold.continuation.dispatchReceiptId,
|
|
2595
|
+
releaseReceiptId: hold.continuation.releaseReceiptId, state: "unknown" }, hold.continuation.targetBinding);
|
|
2596
|
+
this.emit(convId, "agent.collaboration.blocked", { code: error instanceof CollaborationStoreError ? error.code : "dispatch_unknown",
|
|
2597
|
+
holdId: artifactRead.id });
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
1992
2600
|
if (error instanceof AgentSessionError) return; // The definition failure was already persisted and broadcast.
|
|
1993
2601
|
const message = errorMessage(error);
|
|
1994
2602
|
this.emit(convId, "error", refused ? { kind: "compaction-required", message: say(COMPACTION_REQUIRED_MESSAGE) } : this.continuityError(message));
|
|
1995
2603
|
} finally {
|
|
1996
2604
|
releaseAdmission();
|
|
2605
|
+
// An owner input that superseded a peer execution ended that request and queued its requester's reply.
|
|
2606
|
+
if (!authored) { void this.dispatchCollaboration(convId); this.wakeCollaborationReplies(); }
|
|
1997
2607
|
}
|
|
1998
2608
|
}
|
|
1999
2609
|
|
|
@@ -2026,7 +2636,19 @@ export class Hub {
|
|
|
2026
2636
|
if (this.maintenanceId) throw new MaintenanceError("maintenance_held");
|
|
2027
2637
|
const rt = this.rt(convId);
|
|
2028
2638
|
if (!rt.sessionId || !rt.running) return;
|
|
2029
|
-
|
|
2639
|
+
// Stopping a run that executes a peer request is the owner cancelling that request: it is recorded before the abort,
|
|
2640
|
+
// so the aborted run settles once on the cancel path and the requester gets one result-less final reply.
|
|
2641
|
+
const activationId = this.store.agentBinding(convId)?.activationId ?? null;
|
|
2642
|
+
const executing = activationId !== null && (this.store.mainTurn(convId)?.inputs.some(input => {
|
|
2643
|
+
const data: MainInput = JSON.parse(JSON.stringify(input.data));
|
|
2644
|
+
return (data.origin === "agent_collaboration" || data.origin === "agent_tool_permission") && data.input_ref.activation_id === activationId;
|
|
2645
|
+
}) ?? false);
|
|
2646
|
+
const cancelled = executing ? this.store.cancelStoppedAgentExecution(convId, activationId, "owner_stopped") : null;
|
|
2647
|
+
if (cancelled) {
|
|
2648
|
+
this.wakeCollaborationReplies();
|
|
2649
|
+
// Aborts this run and any running execution of a child cancelled with it.
|
|
2650
|
+
await this.interruptCancelledCollaboration();
|
|
2651
|
+
} else await this.requireOmo().command("abort", { sessionId: rt.sessionId });
|
|
2030
2652
|
this.dialogs.cancelScope(this.conversationScope(convId), say(HUB_TEXT.userStopped));
|
|
2031
2653
|
}
|
|
2032
2654
|
|
|
@@ -2140,7 +2762,7 @@ export class Hub {
|
|
|
2140
2762
|
return this.dialogs.answer(request.actionId, answer);
|
|
2141
2763
|
}
|
|
2142
2764
|
|
|
2143
|
-
onEvent(event: OmoEvent):
|
|
2765
|
+
async onEvent(event: OmoEvent): Promise<void> {
|
|
2144
2766
|
this.eventCount += 1;
|
|
2145
2767
|
const sessionId = typeof event.sessionId === "string" ? event.sessionId : null;
|
|
2146
2768
|
const conv = sessionId ? this.sessionToConv.get(sessionId) : undefined;
|
|
@@ -2210,7 +2832,20 @@ export class Hub {
|
|
|
2210
2832
|
if (message?.role === "user") {
|
|
2211
2833
|
const input = this.store.mainUserInput(conv, owner.sessionPath, contentText(message.content), message.clientInputId);
|
|
2212
2834
|
if (input) {
|
|
2213
|
-
const
|
|
2835
|
+
const data: MainInput = JSON.parse(JSON.stringify(input.data));
|
|
2836
|
+
let native: { entry: LedgerEntry; durableId: string } | undefined;
|
|
2837
|
+
switch (data.origin) {
|
|
2838
|
+
case "user": case "work_update": break;
|
|
2839
|
+
case "agent_collaboration": case "agent_tool_permission": case "collaboration_artifact_read": {
|
|
2840
|
+
const ledger = await this.currentLedger(conv, sessionId, this.generation);
|
|
2841
|
+
const entry = ledger && this.ledgerInputEntry(ledger, data);
|
|
2842
|
+
if (!ledger || !entry) return;
|
|
2843
|
+
native = { entry, durableId: ledger.durableId };
|
|
2844
|
+
break;
|
|
2845
|
+
}
|
|
2846
|
+
default: return collaborationNever(data);
|
|
2847
|
+
}
|
|
2848
|
+
const consumed = this.store.consumeMainInput(input, native);
|
|
2214
2849
|
if (consumed) this.publishStored(consumed);
|
|
2215
2850
|
this.startMainTurn(conv);
|
|
2216
2851
|
}
|
|
@@ -2307,18 +2942,34 @@ export class Hub {
|
|
|
2307
2942
|
return;
|
|
2308
2943
|
}
|
|
2309
2944
|
case "agent_settled": {
|
|
2945
|
+
const source = this.store.mainTurn(conv)?.source;
|
|
2946
|
+
if (source && source.origin !== "user" && source.origin !== "work_update") {
|
|
2947
|
+
const generation = this.generation, run = rt.run;
|
|
2948
|
+
const state = await this.requireOmo().command<{ isStreaming?: boolean; pendingMessageCount?: number }>("get_state", { sessionId });
|
|
2949
|
+
if (generation !== this.generation || rt.run !== run || rt.sessionId !== sessionId ||
|
|
2950
|
+
state.isStreaming !== false || state.pendingMessageCount !== 0) return;
|
|
2951
|
+
}
|
|
2952
|
+
this.collaboration?.retire(conv);
|
|
2310
2953
|
rt.running = false;
|
|
2311
2954
|
rt.statsTurnId = null;
|
|
2312
2955
|
rt.statsMessageIndex = 0;
|
|
2313
2956
|
this.publishActivity(conv);
|
|
2314
2957
|
this.unwatchConversation(conv);
|
|
2315
2958
|
this.dialogs.cancelScope(owner.scope, say(HUB_TEXT.turnEnded));
|
|
2959
|
+
this.toolMigrationIdle(conv);
|
|
2316
2960
|
if (this.decisions) {
|
|
2961
|
+
const settledPath = this.store.getConversation(conv)?.threadId;
|
|
2317
2962
|
void this.reconcileDecisionMain(conv)
|
|
2318
|
-
.then(() => this.decisions?.settled(owner.scope))
|
|
2963
|
+
.then(() => { if (this.store.getConversation(conv)?.threadId === settledPath) this.decisions?.settled(owner.scope); })
|
|
2319
2964
|
.catch((error) => console.error("main decision reconciliation:", error));
|
|
2320
2965
|
}
|
|
2321
|
-
|
|
2966
|
+
this.settling.set(conv, (this.settling.get(conv) ?? 0) + 1);
|
|
2967
|
+
const settled = this.settleMainTurn(conv, sessionId).finally(() => {
|
|
2968
|
+
const remaining = (this.settling.get(conv) ?? 1) - 1;
|
|
2969
|
+
if (remaining) this.settling.set(conv, remaining);
|
|
2970
|
+
else this.settling.delete(conv);
|
|
2971
|
+
void this.dispatchCollaboration(conv);
|
|
2972
|
+
});
|
|
2322
2973
|
return conv === "main" && this.modelPending
|
|
2323
2974
|
? Promise.all([settled, this.refreshPendingModel()]).then(() => undefined)
|
|
2324
2975
|
: settled;
|
|
@@ -2328,6 +2979,8 @@ export class Hub {
|
|
|
2328
2979
|
return;
|
|
2329
2980
|
case "session_closed":
|
|
2330
2981
|
case "session_parked":
|
|
2982
|
+
this.collaboration?.retire(conv);
|
|
2983
|
+
this.collaborationIncarnations.delete(conv);
|
|
2331
2984
|
this.sessionToConv.delete(sessionId);
|
|
2332
2985
|
this.releaseScopedUiOwner(owner);
|
|
2333
2986
|
rt.sessionId = null;
|
|
@@ -2403,6 +3056,7 @@ export class Hub {
|
|
|
2403
3056
|
...(event.nativePermission ? { nativePermission: event.nativePermission } : {}) },
|
|
2404
3057
|
deadlineAt: typeof event.timeout === "number" ? Date.now() + event.timeout : request.deadlineAt,
|
|
2405
3058
|
});
|
|
3059
|
+
if (decision) this.linkCollaborationQuestion(owner.conv, decision.id);
|
|
2406
3060
|
this.emit(owner.conv, "action.request", decision ? { ...request, decisionId: decision.id } : request);
|
|
2407
3061
|
}
|
|
2408
3062
|
|
|
@@ -2419,6 +3073,19 @@ export class Hub {
|
|
|
2419
3073
|
this.emit(owner.conv, "action.resolved", resolution);
|
|
2420
3074
|
}
|
|
2421
3075
|
|
|
3076
|
+
/**
|
|
3077
|
+
* A question asked inside a collaboration execution holds that request (and its descendants) in waiting_owner until
|
|
3078
|
+
* the owner's real answer; independent requests keep running. A refused link is reported, never treated as an answer.
|
|
3079
|
+
*/
|
|
3080
|
+
private linkCollaborationQuestion(conv: string, decisionId: string): void {
|
|
3081
|
+
try {
|
|
3082
|
+
if (this.store.linkCurrentAgentDecision(conv, decisionId)) this.emit(conv, "agent.collaboration.waiting_owner", { decisionId });
|
|
3083
|
+
} catch (error) {
|
|
3084
|
+
if (!(error instanceof CollaborationStoreError)) throw error;
|
|
3085
|
+
this.emit(conv, "agent.collaboration.blocked", { code: error.code, decisionId });
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
|
|
2422
3089
|
/** Shows an ask_user_question card once; a replay of a card already in the conversation history only re-arms it. */
|
|
2423
3090
|
private openQuestion(owner: ScopedUiOwner, frame: ScopedQuestionFrame): void {
|
|
2424
3091
|
const actionId = scopedActionId(owner.scope, frame.id);
|
|
@@ -2448,6 +3115,7 @@ export class Hub {
|
|
|
2448
3115
|
...(frame.waitForAnswer === false ? { waitForAnswer: false } : {}) },
|
|
2449
3116
|
deadlineAt: frame.deadlineAtMs ?? null,
|
|
2450
3117
|
});
|
|
3118
|
+
if (decision) this.linkCollaborationQuestion(owner.conv, decision.id);
|
|
2451
3119
|
const open = this.openQuestionCards(owner.conv, owner.scope);
|
|
2452
3120
|
if (open.has(frame.id)) this.legacyQuestionHistoryAliases.set(actionId, frame.id);
|
|
2453
3121
|
if (!open.has(actionId) && !open.has(frame.id)) {
|
|
@@ -2461,6 +3129,23 @@ export class Hub {
|
|
|
2461
3129
|
}
|
|
2462
3130
|
}
|
|
2463
3131
|
|
|
3132
|
+
/**
|
|
3133
|
+
* Bug 236: a newer instruction was queued behind this work's turn. Each required question the turn waits on ends its
|
|
3134
|
+
* native wait (the queued instruction then runs at the tool boundary) and stays open in the inbox.
|
|
3135
|
+
*/
|
|
3136
|
+
releaseScopedQuestions(owner: ScopedUiOwner): void {
|
|
3137
|
+
if (!this.isCurrentScopedUiOwner(owner)) return;
|
|
3138
|
+
for (const [actionId, card] of [...this.questions]) {
|
|
3139
|
+
if (card.scope !== owner.scope || card.targetSessionPath !== owner.sessionPath) continue;
|
|
3140
|
+
const decision = this.store.getDecisionBySource({ scope: owner.scope, sourceSessionPath: owner.sessionPath, sourceRequestId: card.sourceRequestId });
|
|
3141
|
+
if (!decision || !this.decisions?.releaseWorkWait(decision.id)) continue;
|
|
3142
|
+
this.questions.delete(actionId);
|
|
3143
|
+
this.legacyQuestionHistoryAliases.delete(actionId);
|
|
3144
|
+
this.releasedQuestions.add(actionId);
|
|
3145
|
+
this.omo?.send({ type: "extension_ui_response", id: card.sourceRequestId, sessionId: owner.sessionId, answers: {}, comment: WORK_QUESTION_RELEASED });
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
|
|
2464
3149
|
private answerQuestion(card: QuestionCard, data: ActionAnswerData): boolean {
|
|
2465
3150
|
const owner = this.currentScopedUiOwner(card.scope, card.targetSessionPath);
|
|
2466
3151
|
if (!owner || !this.omo) return false;
|
|
@@ -2596,6 +3281,22 @@ export class Hub {
|
|
|
2596
3281
|
if (!ledger) throw new Error("session_history_unavailable");
|
|
2597
3282
|
const currentTurn = this.store.mainTurn(conv.id);
|
|
2598
3283
|
if (!currentTurn || currentTurn.seq !== turn.seq) return;
|
|
3284
|
+
switch (turn.source.origin) {
|
|
3285
|
+
case "agent_collaboration": case "agent_tool_permission": case "collaboration_artifact_read":
|
|
3286
|
+
// Reconcile exact evidence only. Recovery never creates a replacement peer input.
|
|
3287
|
+
for (const input of turn.inputs) {
|
|
3288
|
+
const data: MainInput = JSON.parse(JSON.stringify(input.data));
|
|
3289
|
+
const entry = this.ledgerInputEntry(ledger, data);
|
|
3290
|
+
if (!entry) continue;
|
|
3291
|
+
const consumed = this.store.consumeMainInput(input, { entry, durableId: ledger.durableId });
|
|
3292
|
+
if (consumed) this.publishStored(consumed);
|
|
3293
|
+
}
|
|
3294
|
+
// A native run that finished while no gateway was attached sends no later agent_end; settle it on the same evidence.
|
|
3295
|
+
if (!streaming) await this.settleMainTurn(conv.id, sessionId);
|
|
3296
|
+
return;
|
|
3297
|
+
case "user": case "work_update": break;
|
|
3298
|
+
default: return collaborationNever(turn.source);
|
|
3299
|
+
}
|
|
2599
3300
|
const workInputs = turn.inputs.filter((input) => (input.data as MainInput).origin === "work_update");
|
|
2600
3301
|
for (const input of workInputs) {
|
|
2601
3302
|
const data = input.data as MainInput;
|
|
@@ -2688,7 +3389,7 @@ export class Hub {
|
|
|
2688
3389
|
const lastUser = this.store.getEvent(turn.source.input_ref.message_seq)?.data as { text?: string; images?: string[] } | undefined;
|
|
2689
3390
|
const message = promptReached || !lastUser?.text ? CONTINUE_PROMPT : lastUser.text;
|
|
2690
3391
|
this.emit(conv.id, "notice", { kind: "auto-continue", level: "warning", message: say(HUB_TEXT.autoContinue, { attempt: tries + 1, limit: AUTO_CONTINUE_LIMIT, recovered: recoveredNote(recovered) }) });
|
|
2691
|
-
await this.prompt(conv.id, message, promptReached ? [] : lastUser?.images ?? [], turn.source.input_ref.message_seq);
|
|
3392
|
+
await this.prompt(conv.id, message, { images: promptReached ? [] : lastUser?.images ?? [], messageSeq: turn.source.input_ref.message_seq });
|
|
2692
3393
|
}
|
|
2693
3394
|
|
|
2694
3395
|
private hasUnfinishedTurn(conv: string): boolean {
|
|
@@ -2812,7 +3513,12 @@ export class Hub {
|
|
|
2812
3513
|
}
|
|
2813
3514
|
|
|
2814
3515
|
private ledgerInputEntry(ledger: LedgerState, data: MainInput): LedgerEntry | null {
|
|
2815
|
-
|
|
3516
|
+
switch (data.origin) {
|
|
3517
|
+
case "work_update": return null;
|
|
3518
|
+
case "user": case "agent_collaboration": case "agent_tool_permission": case "collaboration_artifact_read": break;
|
|
3519
|
+
default: return collaborationNever(data);
|
|
3520
|
+
}
|
|
3521
|
+
if (typeof data.input_id !== "string" || data.session_path !== ledger.sessionPath) return null;
|
|
2816
3522
|
if (data.native_identity) return this.store.ledgerUser(ledger.sessionPath, { clientInputId: data.input_id });
|
|
2817
3523
|
return typeof data.wire_message === "string" ? this.store.ledgerUser(ledger.sessionPath, { text: data.wire_message }) : null;
|
|
2818
3524
|
}
|
|
@@ -2893,6 +3599,8 @@ export class Hub {
|
|
|
2893
3599
|
throw new AgentSessionError();
|
|
2894
3600
|
}
|
|
2895
3601
|
}
|
|
3602
|
+
const resumed = existsSync(sessionPath);
|
|
3603
|
+
const agentMode = conv.agentId ? this.store.toolPermissions.mode(conv.agentId) : null;
|
|
2896
3604
|
const opened = this.requireOmo().command<{ sessionId: string; attached?: boolean; state?: {
|
|
2897
3605
|
isStreaming?: boolean; pendingQuestions?: ScopedQuestionFrame[];
|
|
2898
3606
|
clientInputIdentity?: boolean; atomicQueuedResume?: boolean;
|
|
@@ -2903,15 +3611,16 @@ export class Hub {
|
|
|
2903
3611
|
...(model ?? {}),
|
|
2904
3612
|
...(typeof agent?.fields.thinking === "string" ? { thinkingLevel: agent.fields.thinking } : {}),
|
|
2905
3613
|
cwd,
|
|
2906
|
-
permissionPreset: this.options.permissionPreset,
|
|
3614
|
+
permissionPreset: agentSessionPreset(this.options.permissionPreset, agentMode),
|
|
2907
3615
|
retain_on_disconnect: true,
|
|
2908
3616
|
kind: "interactive",
|
|
2909
3617
|
promptSurface: "app",
|
|
2910
3618
|
context: conv.agentId
|
|
2911
|
-
|
|
3619
|
+
// C6: the session's adapter learns that the agent authority (not a native preset) governs it.
|
|
3620
|
+
? { zgw_conv: conv.id, zgw_role: "agent", zgw_agent: conv.agentId, ...(agentMode === "managed" ? { zgw_tool_authority: "managed" } : {}) }
|
|
2912
3621
|
: { zgw_conv: conv.id, zgw_role: "main" },
|
|
2913
3622
|
});
|
|
2914
|
-
return { sessionPath, opened, agent };
|
|
3623
|
+
return { sessionPath, opened, agent, resumed };
|
|
2915
3624
|
}
|
|
2916
3625
|
|
|
2917
3626
|
private watchAsk(conv: string, toolCallId: string, args: unknown): void {
|
|
@@ -2964,12 +3673,13 @@ export class Hub {
|
|
|
2964
3673
|
if (rt.sessionId) return { sessionId: rt.sessionId, streaming: rt.running };
|
|
2965
3674
|
if (rt.opening) return rt.opening;
|
|
2966
3675
|
const generation = this.generation;
|
|
2967
|
-
const { sessionPath, opened, agent } = this.openSession(conv);
|
|
3676
|
+
const { sessionPath, opened, agent, resumed } = this.openSession(conv);
|
|
2968
3677
|
const opening = opened
|
|
2969
3678
|
.then(async (data) => {
|
|
2970
3679
|
if (generation !== this.generation || this.omoStatus !== "ready") throw new Error("stale_open_session");
|
|
2971
3680
|
rt.sessionId = data.sessionId;
|
|
2972
3681
|
rt.durableId = typeof data.state?.sessionId === "string" ? data.state.sessionId : null;
|
|
3682
|
+
this.collaborationIncarnations.set(conv.id, crypto.randomUUID());
|
|
2973
3683
|
rt.clientInputIdentity = data.state?.clientInputIdentity === true;
|
|
2974
3684
|
rt.atomicQueuedResume = data.state?.atomicQueuedResume === true;
|
|
2975
3685
|
this.sessionToConv.set(data.sessionId, conv.id);
|
|
@@ -2980,19 +3690,65 @@ export class Hub {
|
|
|
2980
3690
|
}
|
|
2981
3691
|
const owner = { conv: conv.id, scope: this.conversationScope(conv.id), sessionId: data.sessionId, sessionPath };
|
|
2982
3692
|
if (!this.bindScopedUiOwner(owner)) throw new Error("omo_owner_not_ready");
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
3693
|
+
await this.bindDocuments(conv, data.sessionId, sessionPath, rt, generation);
|
|
3694
|
+
await this.bindAgentTools(conv, data.sessionId, sessionPath, rt, generation);
|
|
3695
|
+
if (agent) {
|
|
3696
|
+
const rotated = this.store.lastSeq(conv.id, "session.rotated");
|
|
2986
3697
|
let warnings: { code: string; message: string }[] = [];
|
|
3698
|
+
let appliedSha: string | undefined;
|
|
2987
3699
|
try {
|
|
2988
|
-
const result = await this.requireOmo().command<{ warnings?: { code: string; message: string }[] } | null>(
|
|
3700
|
+
const result = await this.requireOmo().command<{ warnings?: { code: string; message: string }[]; appliedSha?: unknown } | null>(
|
|
2989
3701
|
"extension_request", { sessionId: data.sessionId, name: "zgw.agent_warnings" });
|
|
3702
|
+
if (typeof result?.appliedSha === "string") appliedSha = result.appliedSha;
|
|
2990
3703
|
if (Array.isArray(result?.warnings)) warnings = result.warnings;
|
|
2991
3704
|
else console.error(`agent startup warnings failed (${conv.id}): invalid warnings response`);
|
|
2992
3705
|
} catch (error) {
|
|
2993
3706
|
console.error(`agent startup warnings failed (${conv.id}): ${errorMessage(error)}`);
|
|
2994
3707
|
}
|
|
2995
|
-
|
|
3708
|
+
// A report belongs to the session that requested it, even if a native handle is reused.
|
|
3709
|
+
if (generation !== this.generation || rt.sessionId !== data.sessionId ||
|
|
3710
|
+
rotated !== this.store.lastSeq(conv.id, "session.rotated")) throw new Error("stale_open_session");
|
|
3711
|
+
const last = this.store.eventsAfter(conv.id, this.store.lastSeq(conv.id, "session.rotated"))
|
|
3712
|
+
.filter(event => event.type === "agent.applied").at(-1)?.data;
|
|
3713
|
+
const previous = typeof last === "object" && last !== null ? last : undefined;
|
|
3714
|
+
let snapshot: Record<string, unknown> | undefined;
|
|
3715
|
+
if (data.attached !== true && !resumed) {
|
|
3716
|
+
// Only a new file uses creation defaults. Old extensions retain the open-time fallback.
|
|
3717
|
+
snapshot = appliedSha === undefined || appliedSha === agent.sha
|
|
3718
|
+
? { sha: agent.sha, model: agent.fields.model ?? null, thinking: agent.fields.thinking ?? null }
|
|
3719
|
+
: { sha: null, mixed: true };
|
|
3720
|
+
} else if (appliedSha !== undefined) {
|
|
3721
|
+
if (data.attached === true) {
|
|
3722
|
+
// A retained native session never reloads its creation controls.
|
|
3723
|
+
if (previous && "sha" in previous && previous.sha !== appliedSha) snapshot = { sha: null, mixed: true };
|
|
3724
|
+
} else if (!previous || !("sha" in previous) || previous.sha !== appliedSha) {
|
|
3725
|
+
// Cold resume reloads the extension, but keeps the model/thinking in the file.
|
|
3726
|
+
let loaded = agent;
|
|
3727
|
+
if (loaded.sha !== appliedSha && conv.agentId) {
|
|
3728
|
+
try {
|
|
3729
|
+
const files = this.options.agents ?? new AgentFileStore();
|
|
3730
|
+
loaded = mergeAgentDefinition(files.read(conv.agentId), files.readDefaults());
|
|
3731
|
+
} catch {
|
|
3732
|
+
// A concurrent invalid save cannot prove which definition the extension loaded.
|
|
3733
|
+
console.error(`agent resumed definition unavailable (${conv.id})`);
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
snapshot = loaded.sha === appliedSha && previous && "model" in previous && "thinking" in previous &&
|
|
3737
|
+
isDeepStrictEqual(previous.model, loaded.fields.model ?? null) && previous.thinking === (loaded.fields.thinking ?? null)
|
|
3738
|
+
? { sha: appliedSha, model: loaded.fields.model ?? null, thinking: loaded.fields.thinking ?? null }
|
|
3739
|
+
: { sha: null, mixed: true };
|
|
3740
|
+
}
|
|
3741
|
+
}
|
|
3742
|
+
if (snapshot) {
|
|
3743
|
+
// Reattachment does not change the live notification policy.
|
|
3744
|
+
const notify = data.attached === true
|
|
3745
|
+
? previous !== undefined && "notify" in previous && previous.notify === true
|
|
3746
|
+
: agent.fields.notify === true;
|
|
3747
|
+
this.emit(conv.id, "agent.applied", { ...snapshot, at: new Date(this.now()).toISOString(), notify });
|
|
3748
|
+
}
|
|
3749
|
+
if (data.attached !== true) {
|
|
3750
|
+
for (const warning of warnings) this.emit(conv.id, "notice", { kind: "agent-warning", level: "warning", ...warning });
|
|
3751
|
+
}
|
|
2996
3752
|
}
|
|
2997
3753
|
this.syncScopedQuestions(owner, data.state?.pendingQuestions ?? []);
|
|
2998
3754
|
this.publishActivity(conv.id);
|
|
@@ -3024,6 +3780,84 @@ export class Hub {
|
|
|
3024
3780
|
return rt;
|
|
3025
3781
|
}
|
|
3026
3782
|
|
|
3783
|
+
/** 294: host-only engine commands for the conversation's current session; null while it has none. */
|
|
3784
|
+
agentToolEngine(convId: string): ((type: string, params: Record<string, unknown>) => Promise<unknown>) | null {
|
|
3785
|
+
const sessionId = this.runtime.get(convId)?.sessionId;
|
|
3786
|
+
if (!sessionId || this.omoStatus !== "ready") return null;
|
|
3787
|
+
return (type, params) => this.requireOmo().command(type, { ...params, sessionId });
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
/**
|
|
3791
|
+
* 294 item 11: the migration card of a legacy agent, built from its current definition (C9). The same open card is
|
|
3792
|
+
* returned again; a managed agent (new, clone, or already switched) gets null. Creating the card grants nothing and
|
|
3793
|
+
* never blocks or restarts a running turn.
|
|
3794
|
+
*/
|
|
3795
|
+
ensureToolMigration(agentId: string, files: AgentFileStore = this.options.agents ?? new AgentFileStore()): AgentToolMigrationCard | null {
|
|
3796
|
+
const ledger = this.store.toolPermissions;
|
|
3797
|
+
const current = ledger.currentMigration(agentId);
|
|
3798
|
+
if (current && current.state !== "applied") return current;
|
|
3799
|
+
if (ledger.mode(agentId) === "managed") return null;
|
|
3800
|
+
const conv = this.store.listConversations(true).find((row) => row.agentId === agentId);
|
|
3801
|
+
if (!conv) return null;
|
|
3802
|
+
const merged = mergeAgentDefinition(files.read(agentId), files.readDefaults());
|
|
3803
|
+
const card = this.store.createAgentToolMigration(agentId, conv.id, legacyToolScopes(merged, join(files.root, agentId, "knowledge")));
|
|
3804
|
+
this.decisions?.refresh();
|
|
3805
|
+
return card;
|
|
3806
|
+
}
|
|
3807
|
+
|
|
3808
|
+
/**
|
|
3809
|
+
* 294 item 11: an idle conversation about to start a turn is also an idle boundary, so a card answered while the agent
|
|
3810
|
+
* was idle applies to the next turn. A prompt steered into a running turn switches nothing; that turn ends legacy.
|
|
3811
|
+
*/
|
|
3812
|
+
toolMigrationBeforeTurn(convId: string): void {
|
|
3813
|
+
if (!this.rt(convId).running) this.toolMigrationIdle(convId);
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
/** 294 item 11: the idle boundary after a turn. An answered migration card switches its agent to managed here only. */
|
|
3817
|
+
toolMigrationIdle(convId: string): void {
|
|
3818
|
+
const agentId = this.store.getConversation(convId)?.agentId;
|
|
3819
|
+
if (!agentId) return;
|
|
3820
|
+
try {
|
|
3821
|
+
if (this.store.toolPermissions.applyMigrationAtIdle(agentId)) this.decisions?.refresh();
|
|
3822
|
+
} catch (error) {
|
|
3823
|
+
console.error(`agent tool migration switch failed (${convId}): ${errorMessage(error)}`);
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
/** 294: rotates the agent session's internal-route secret on every native open; a failed bind leaves only refused calls. */
|
|
3828
|
+
private async bindAgentTools(conv: ConversationInfo, sessionId: string, sessionPath: string, rt: ConvRuntime, generation: number): Promise<void> {
|
|
3829
|
+
if (conv.agentId) {
|
|
3830
|
+
try {
|
|
3831
|
+
this.ensureToolMigration(conv.agentId);
|
|
3832
|
+
} catch (error) {
|
|
3833
|
+
console.error(`agent tool migration card failed (${conv.id}): ${errorMessage(error)}`);
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
const agentTools = this.options.agentTools;
|
|
3837
|
+
if (!agentTools || !conv.agentId || !rt.durableId) return;
|
|
3838
|
+
const issued = agentTools.bindings.bind({
|
|
3839
|
+
role: "agent", agentId: conv.agentId, convId: conv.id, convEpoch: 1, lifecycleEpoch: this.store.toolPermissions.policyGrants(conv.agentId).view.lifecycleEpoch,
|
|
3840
|
+
sessionPath, durableSessionId: rt.durableId,
|
|
3841
|
+
}, () => generation === this.generation && rt.sessionId === sessionId);
|
|
3842
|
+
try {
|
|
3843
|
+
await this.requireOmo().command("extension_request", { sessionId, name: "zgw.agent_tool_bind", data: { secret: issued.secret, identity: issued.identity } });
|
|
3844
|
+
} catch (error) {
|
|
3845
|
+
agentTools.bindings.retire(conv.id);
|
|
3846
|
+
console.error(`agent tool bind failed (${conv.id}): ${errorMessage(error)}`);
|
|
3847
|
+
return;
|
|
3848
|
+
}
|
|
3849
|
+
// D2: the authority is in place before the first turn, so an ungranted call is held for consent, not refused.
|
|
3850
|
+
try {
|
|
3851
|
+
const omo = this.requireOmo();
|
|
3852
|
+
const pushed = await pushSessionPolicy({ ledger: this.store.toolPermissions, engine: engineCommands((type, params) => omo.command(type, { ...params, sessionId })) },
|
|
3853
|
+
{ agentId: conv.agentId, durableSessionId: rt.durableId });
|
|
3854
|
+
if (pushed.state === "policy_unacknowledged") console.error(`agent tool policy not acknowledged (${conv.id}): revision ${pushed.revision}`);
|
|
3855
|
+
} catch (error) {
|
|
3856
|
+
console.error(`agent tool policy push failed (${conv.id}): ${errorMessage(error)}`);
|
|
3857
|
+
}
|
|
3858
|
+
agentTools.ready?.(conv.id);
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3027
3861
|
private nextId(prefix: string): string {
|
|
3028
3862
|
return `${prefix}${Date.now().toString(36)}${(this.counter++).toString(36)}`;
|
|
3029
3863
|
}
|
|
@@ -3062,7 +3896,11 @@ export class Hub {
|
|
|
3062
3896
|
console.error(`transcript append failed (${conv}): ${errorMessage(error)}`);
|
|
3063
3897
|
}
|
|
3064
3898
|
}
|
|
3065
|
-
this.broadcast({ v: PROTOCOL_VERSION, id: this.nextId("e"), ts: stored.ts, conv, type, data, seq: stored.seq });
|
|
3899
|
+
this.broadcast({ v: PROTOCOL_VERSION, id: this.nextId("e"), ts: stored.ts, conv, type, data: this.withAttachmentState(stored).data, seq: stored.seq });
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
private withAttachmentState(stored: StoredEvent): StoredEvent {
|
|
3903
|
+
return stored.type === "message.user" ? { ...stored, data: this.store.attachments.withGrantState(stored.data) } : stored;
|
|
3066
3904
|
}
|
|
3067
3905
|
}
|
|
3068
3906
|
|
|
@@ -3143,12 +3981,228 @@ function turnFinished(entries: readonly LedgerEntry[]): boolean {
|
|
|
3143
3981
|
return last?.role === "assistant" && last.stopReason === "stop";
|
|
3144
3982
|
}
|
|
3145
3983
|
|
|
3984
|
+
function turnAborted(entries: readonly LedgerEntry[]): boolean {
|
|
3985
|
+
const last = entries.filter((entry) => entry.type === "message").at(-1);
|
|
3986
|
+
return last?.role === "assistant" && last.stopReason === "aborted";
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3146
3989
|
/** The sentence added to a recovery notice for answers the gateway filled in while it was away (exported for tests). */
|
|
3147
3990
|
export function recoveredNote(count: number): string {
|
|
3148
3991
|
if (count <= 0) return "";
|
|
3149
3992
|
return count === 1 ? say(HUB_TEXT.recoveredOne) : say(HUB_TEXT.recoveredMany, { count });
|
|
3150
3993
|
}
|
|
3151
3994
|
|
|
3995
|
+
/**
|
|
3996
|
+
* 294 C6: a registered agent under managed tool permissions has one authority, the gateway's agent authority, which runs
|
|
3997
|
+
* before every native hook. Its session therefore opens full-access, so an owner-allowed stored call is not asked a second
|
|
3998
|
+
* time by a native preset prompt nobody answers. Main, work and legacy agents keep the gateway preset.
|
|
3999
|
+
*/
|
|
4000
|
+
export function agentSessionPreset(gatewayPreset: string, agentMode: "legacy" | "managed" | null): string {
|
|
4001
|
+
return agentMode === "managed" ? "full-access" : gatewayPreset;
|
|
4002
|
+
}
|
|
4003
|
+
|
|
3152
4004
|
function errorMessage(error: unknown): string {
|
|
3153
4005
|
return error instanceof Error ? error.message : String(error);
|
|
3154
4006
|
}
|
|
4007
|
+
|
|
4008
|
+
export const AGENT_TOOL_INTERNAL_ROUTES: Readonly<Record<string, { method: "GET" | "POST"; operation: string }>> = {
|
|
4009
|
+
"/internal/agent-tools/request": { method: "POST", operation: "agent_tool.request" },
|
|
4010
|
+
"/internal/agent-tools/action": { method: "POST", operation: "agent_tool.action" },
|
|
4011
|
+
"/internal/agent-tools/reserve": { method: "POST", operation: "agent_tool.reserve" },
|
|
4012
|
+
"/internal/agent-tools/start": { method: "POST", operation: "agent_tool.start" },
|
|
4013
|
+
"/internal/agent-tools/result": { method: "POST", operation: "agent_tool.result" },
|
|
4014
|
+
"/internal/agent-tools/policy": { method: "GET", operation: "agent_tool.policy" },
|
|
4015
|
+
"/internal/agent-tools/policy-ack": { method: "POST", operation: "agent_tool.policyAck" },
|
|
4016
|
+
};
|
|
4017
|
+
|
|
4018
|
+
interface AgentToolLive { readonly session: ManagedSession; readonly incarnation: string; readonly secret: KeyObject; readonly live: () => boolean; readonly nonces: Set<string> }
|
|
4019
|
+
|
|
4020
|
+
/**
|
|
4021
|
+
* 294 custody of the agent session secrets for /internal/agent-tools/*. Each bind rotates the secret; a call is accepted
|
|
4022
|
+
* only for an agent role, the live incarnation of this gateway generation, and a logical call id tied to its tool call.
|
|
4023
|
+
*/
|
|
4024
|
+
export class AgentToolBindings {
|
|
4025
|
+
readonly generation = randomUUID();
|
|
4026
|
+
private readonly byConv = new Map<string, AgentToolLive>();
|
|
4027
|
+
bind(session: ManagedSession, live: () => boolean): { secret: string; identity: Omit<ManagedCallIdentity, "inputId" | "toolCallId" | "logicalCallId" | "activationId"> } {
|
|
4028
|
+
const raw = randomBytes(32), incarnation = randomUUID();
|
|
4029
|
+
this.byConv.set(session.convId, { session, incarnation, secret: createSecretKey(raw), live, nonces: new Set() });
|
|
4030
|
+
return { secret: raw.toString("hex"), identity: { ...session, sessionIncarnation: incarnation, gatewayGeneration: this.generation } };
|
|
4031
|
+
}
|
|
4032
|
+
retire(convId: string): void { this.byConv.delete(convId); }
|
|
4033
|
+
readonly resolver: ManagedCallBindingResolver = (identity): ManagedCallBinding | null => {
|
|
4034
|
+
const record = this.byConv.get(identity.convId);
|
|
4035
|
+
if (!record || !record.live() || record.session.role !== "agent" || record.session.agentId === null ||
|
|
4036
|
+
identity.logicalCallId !== `agent-tool:${identity.toolCallId}`) return null;
|
|
4037
|
+
return {
|
|
4038
|
+
identity: { ...record.session, sessionIncarnation: record.incarnation, gatewayGeneration: this.generation,
|
|
4039
|
+
inputId: identity.inputId, toolCallId: identity.toolCallId, logicalCallId: identity.logicalCallId, activationId: identity.activationId },
|
|
4040
|
+
secret: record.secret,
|
|
4041
|
+
claimNonce: (nonce) => {
|
|
4042
|
+
if (record.nonces.has(nonce) || record.nonces.size >= 65_536) return false;
|
|
4043
|
+
record.nonces.add(nonce);
|
|
4044
|
+
return true;
|
|
4045
|
+
},
|
|
4046
|
+
};
|
|
4047
|
+
};
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
const agentToolRefuse = (error: string, status: number) => Response.json({ error }, { status, headers: { "Cache-Control": "no-store" } });
|
|
4051
|
+
const agentToolText = (value: unknown): value is string => typeof value === "string" && value.length > 0;
|
|
4052
|
+
const agentToolInt = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
4053
|
+
const agentToolRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4054
|
+
const agentToolLabel = (value: unknown): value is string => typeof value === "string" && value.length <= 200;
|
|
4055
|
+
export type AgentToolActionScope = { operation: "click" | "fill"; args: Record<string, unknown>; scope: Record<string, string> };
|
|
4056
|
+
|
|
4057
|
+
/**
|
|
4058
|
+
* C8: the card scope of a click/fill the session prepared on a granted target. The owner sees the element and the exact
|
|
4059
|
+
* value; the target handle stays opaque. Anything malformed is refused, never shown partly. The scope kind follows the session's
|
|
4060
|
+
* own target: a work tab the session opened is `owned_target` (delivery never looks it up in the gateway registry, which holds
|
|
4061
|
+
* only borrowed tabs); a tab the owner designated is `browser_target` and still needs its gateway-bound handle.
|
|
4062
|
+
*/
|
|
4063
|
+
export function agentToolActionScope(input: Record<string, unknown>): AgentToolActionScope {
|
|
4064
|
+
const { operation, args, target, element } = input;
|
|
4065
|
+
if ((operation !== "click" && operation !== "fill") || !agentToolRecord(args) || args.operation !== operation || !agentToolText(args.targetHandle) ||
|
|
4066
|
+
!agentToolRecord(target) || !agentToolText(target.origin) || target.origin.length > 2048 || (target.kind !== "owned" && target.kind !== "borrowed") || !agentToolRecord(element) ||
|
|
4067
|
+
!agentToolLabel(element.tag) || !agentToolLabel(element.role) || !agentToolLabel(element.name) || (operation === "fill") !== agentToolText(args.text)) {
|
|
4068
|
+
throw new AgentToolPermissionError("tool_request_invalid");
|
|
4069
|
+
}
|
|
4070
|
+
const label = `${element.tag}${element.role ? ` [${element.role}]` : ""}${element.name ? ` "${element.name}"` : ""}`;
|
|
4071
|
+
return { operation, args, scope: { kind: target.kind === "owned" ? "owned_target" : "browser_target", targetHandle: args.targetHandle, targetLabel: target.origin, element: label, ...(operation === "fill" ? { value: args.text as string } : {}) } };
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
/**
|
|
4075
|
+
* 294 C3 internal routes. Listener and bearer first, then the signed session identity (shared managed-call verifier).
|
|
4076
|
+
* The agent, conversation and session come from the verified identity only; body agent names are never read.
|
|
4077
|
+
* Policy ACKs are kept per durable session in memory: a gateway restart means the engine must ACK again.
|
|
4078
|
+
*/
|
|
4079
|
+
export class AgentToolInternalApi {
|
|
4080
|
+
constructor(private readonly d: {
|
|
4081
|
+
readonly store: Store;
|
|
4082
|
+
readonly resolver: ManagedCallBindingResolver;
|
|
4083
|
+
readonly omoToken: string;
|
|
4084
|
+
readonly changed: () => void;
|
|
4085
|
+
readonly policy: (view: AgentAccessView, grants: readonly AgentToolGrantRow[]) => unknown;
|
|
4086
|
+
}) {}
|
|
4087
|
+
|
|
4088
|
+
static route(pathname: string): boolean { return Object.hasOwn(AGENT_TOOL_INTERNAL_ROUTES, pathname); }
|
|
4089
|
+
|
|
4090
|
+
|
|
4091
|
+
async handle(req: Request, trusted: boolean): Promise<Response> {
|
|
4092
|
+
const denied = maintenanceAuth(req, trusted, this.d.omoToken);
|
|
4093
|
+
if (denied) return denied;
|
|
4094
|
+
const route = new URL(req.url).pathname;
|
|
4095
|
+
const spec = AGENT_TOOL_INTERNAL_ROUTES[route];
|
|
4096
|
+
if (!spec) return agentToolRefuse("not_found", 404);
|
|
4097
|
+
if (req.method !== spec.method) return agentToolRefuse("method_not_allowed", 405);
|
|
4098
|
+
const body = new Uint8Array(await req.arrayBuffer());
|
|
4099
|
+
if (body.byteLength > 64 * 1024) return agentToolRefuse("tool_request_too_large", 413);
|
|
4100
|
+
let call: VerifiedManagedCall;
|
|
4101
|
+
try {
|
|
4102
|
+
let proof: unknown;
|
|
4103
|
+
try { proof = JSON.parse(Buffer.from(req.headers.get("x-zigmini-managed-call") ?? "", "base64").toString("utf8")); }
|
|
4104
|
+
catch { throw new ManagedCallError("invalid_proof"); }
|
|
4105
|
+
call = verifyManagedCall({ method: spec.method, route, operation: spec.operation, body, proof }, this.d.resolver);
|
|
4106
|
+
} catch (error) {
|
|
4107
|
+
if (error instanceof ManagedCallError) return agentToolRefuse(error.code, error.code === "invalid_proof" ? 401 : 403);
|
|
4108
|
+
throw error;
|
|
4109
|
+
}
|
|
4110
|
+
if (call.role !== "agent" || call.agentId === null) return agentToolRefuse("agent_role_required", 403);
|
|
4111
|
+
let input: Record<string, unknown> = {};
|
|
4112
|
+
if (spec.method === "POST") {
|
|
4113
|
+
const parsed: unknown = (() => { try { return JSON.parse(new TextDecoder().decode(body)); } catch { return null; } })();
|
|
4114
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return agentToolRefuse("tool_request_invalid", 400);
|
|
4115
|
+
input = parsed as Record<string, unknown>;
|
|
4116
|
+
}
|
|
4117
|
+
try {
|
|
4118
|
+
return Response.json(this.run(route, call, call.agentId, input), { headers: { "Cache-Control": "no-store" } });
|
|
4119
|
+
} catch (error) {
|
|
4120
|
+
if (error instanceof AgentToolPermissionError) return agentToolRefuse(error.code, error.status);
|
|
4121
|
+
if (error instanceof CollaborationStoreError) return agentToolRefuse(error.code, error.status);
|
|
4122
|
+
throw error;
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
private owned(agentId: string, invocationId: unknown): string {
|
|
4127
|
+
if (!agentToolText(invocationId)) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4128
|
+
const invocation = this.d.store.toolPermissions.invocation(invocationId);
|
|
4129
|
+
if (!invocation || invocation.agentId !== agentId) throw new AgentToolPermissionError("invocation_not_found");
|
|
4130
|
+
return invocationId;
|
|
4131
|
+
}
|
|
4132
|
+
|
|
4133
|
+
private policyOf(agentId: string): { revision: number; policy: unknown } {
|
|
4134
|
+
const { view, grants } = this.d.store.toolPermissions.policyGrants(agentId);
|
|
4135
|
+
// The engine requires revision >= 1, so an agent with no permission change yet is revision 1 on both sides.
|
|
4136
|
+
return { revision: Math.max(view.policyRevision, 1), policy: this.d.policy(view, grants) };
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
private run(route: string, call: VerifiedManagedCall, agentId: string, input: Record<string, unknown>): unknown {
|
|
4140
|
+
const ledger = this.d.store.toolPermissions;
|
|
4141
|
+
switch (route) {
|
|
4142
|
+
case "/internal/agent-tools/request": {
|
|
4143
|
+
if (!agentToolText(input.operation) || typeof input.toolKey !== "object" || input.toolKey === null) {
|
|
4144
|
+
throw new AgentToolPermissionError("tool_request_invalid");
|
|
4145
|
+
}
|
|
4146
|
+
// Q-07: a call signed with a collaboration activation must name its request (and only such a call may).
|
|
4147
|
+
const activationId = call.activationId, collaborationRequestId = input.collaborationRequestId;
|
|
4148
|
+
if ((activationId !== null) !== agentToolText(collaborationRequestId)) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4149
|
+
const card = {
|
|
4150
|
+
agentId, convId: call.convId,
|
|
4151
|
+
// The engine held this call under agent-op:<toolCallId>; delivery executes exactly that operation.
|
|
4152
|
+
durableSessionId: call.durableSessionId, logicalCallId: agentToolOperationId(call.toolCallId), sourceToolCallId: call.toolCallId,
|
|
4153
|
+
toolKey: input.toolKey as AgentToolRequestInput["toolKey"], operation: input.operation, args: input.args ?? null, scope: input.scope ?? null,
|
|
4154
|
+
};
|
|
4155
|
+
let request: AgentToolRequestRecord;
|
|
4156
|
+
// A collaboration card's original input is its execution's input (the Store reads it from the bound activation) and the
|
|
4157
|
+
// card is linked to that request in the same transaction (the request then waits for the owner).
|
|
4158
|
+
if (activationId !== null && agentToolText(collaborationRequestId)) request = this.d.store.createCollaborationToolRequest({ ...card, originKind: "agent_collaboration", collaborationRequestId, activationId });
|
|
4159
|
+
else {
|
|
4160
|
+
// C3: an owner card's original input comes from the trusted conversation, never from the body: its latest accepted user message.
|
|
4161
|
+
const originInputSeq = this.d.store.lastSeq(call.convId, "message.user");
|
|
4162
|
+
if (!agentToolInt(originInputSeq)) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4163
|
+
request = this.d.store.createAgentToolRequest({ ...card, originInputSeq, originKind: "user", collaborationRequestId: null, activationId: null });
|
|
4164
|
+
}
|
|
4165
|
+
this.d.changed();
|
|
4166
|
+
return { state: "permission_pending", requestId: request.id, decisionId: request.decisionId };
|
|
4167
|
+
}
|
|
4168
|
+
case "/internal/agent-tools/action": {
|
|
4169
|
+
// C8: one agent_tool_action card per click/fill call; the session recorded the call as agent-action:<toolCallId>.
|
|
4170
|
+
const { operation, args, scope } = agentToolActionScope(input);
|
|
4171
|
+
if (typeof input.toolKey !== "object" || input.toolKey === null) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4172
|
+
const originInputSeq = this.d.store.lastSeq(call.convId, "message.user");
|
|
4173
|
+
if (!agentToolInt(originInputSeq)) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4174
|
+
const request = this.d.store.createAgentToolAction({
|
|
4175
|
+
agentId, convId: call.convId, originInputSeq, originKind: "user", durableSessionId: call.durableSessionId,
|
|
4176
|
+
logicalCallId: agentToolActionId(call.toolCallId), sourceToolCallId: call.toolCallId,
|
|
4177
|
+
toolKey: input.toolKey as AgentToolRequestInput["toolKey"], operation: ASIDE_OPERATIONS[operation]!, args, scope,
|
|
4178
|
+
});
|
|
4179
|
+
this.d.changed();
|
|
4180
|
+
return { state: "action_confirmation_pending", requestId: request.id, decisionId: request.decisionId };
|
|
4181
|
+
}
|
|
4182
|
+
case "/internal/agent-tools/reserve":
|
|
4183
|
+
if (!agentToolInt(input.executorGeneration)) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4184
|
+
return ledger.reserve(this.owned(agentId, input.invocationId), input.executorGeneration);
|
|
4185
|
+
case "/internal/agent-tools/start": {
|
|
4186
|
+
if (!agentToolInt(input.executorGeneration)) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4187
|
+
// The ledger checks the persisted ACK of the current revision in the start transaction (C5).
|
|
4188
|
+
return ledger.start(this.owned(agentId, input.invocationId), input.executorGeneration, { durableSessionId: call.durableSessionId });
|
|
4189
|
+
}
|
|
4190
|
+
case "/internal/agent-tools/result": {
|
|
4191
|
+
const state = input.state;
|
|
4192
|
+
if (!agentToolInt(input.executorGeneration) || !agentToolText(input.resultRef) ||
|
|
4193
|
+
(state !== "succeeded" && state !== "failed" && state !== "outcome_unknown")) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4194
|
+
return ledger.result(this.owned(agentId, input.invocationId), input.executorGeneration, { state, resultRef: input.resultRef });
|
|
4195
|
+
}
|
|
4196
|
+
case "/internal/agent-tools/policy": {
|
|
4197
|
+
// The engine matches by name; `operations` lets an adapter (zgw_aside) refuse an operation the grant does not name.
|
|
4198
|
+
const { view, grants } = ledger.policyGrants(agentId);
|
|
4199
|
+
return { ...(this.policyOf(agentId).policy as object), operations: grantedOperations(view, grants) };
|
|
4200
|
+
}
|
|
4201
|
+
case "/internal/agent-tools/policy-ack": {
|
|
4202
|
+
if (!agentToolInt(input.revision)) throw new AgentToolPermissionError("tool_request_invalid");
|
|
4203
|
+
return ledger.ackPolicy(call.durableSessionId, agentId, input.revision);
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
throw new AgentToolPermissionError("tool_request_invalid");
|
|
4207
|
+
}
|
|
4208
|
+
}
|