@stigmer/runner 3.0.9-dev.20260615153829 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/__test-utils__/approval-contract/types.d.ts +174 -0
- package/dist/__test-utils__/approval-contract/types.js +24 -0
- package/dist/__test-utils__/approval-contract/types.js.map +1 -0
- package/dist/activities/call-agent-status.d.ts +19 -1
- package/dist/activities/call-agent-status.js +64 -5
- package/dist/activities/call-agent-status.js.map +1 -1
- package/dist/activities/call-llm.js +19 -53
- package/dist/activities/call-llm.js.map +1 -1
- package/dist/activities/classify-tool-approvals.d.ts +24 -0
- package/dist/activities/classify-tool-approvals.js +69 -17
- package/dist/activities/classify-tool-approvals.js.map +1 -1
- package/dist/activities/discover-mcp-server.d.ts +7 -0
- package/dist/activities/discover-mcp-server.js +11 -1
- package/dist/activities/discover-mcp-server.js.map +1 -1
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +140 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +130 -0
- package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.d.ts +18 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js +123 -0
- package/dist/activities/execute-cursor/__test-utils__/gateway-substrate.js.map +1 -0
- package/dist/activities/execute-cursor/approval-policy.d.ts +17 -61
- package/dist/activities/execute-cursor/approval-policy.js +8 -120
- package/dist/activities/execute-cursor/approval-policy.js.map +1 -1
- package/dist/activities/execute-cursor/approval-state.d.ts +219 -9
- package/dist/activities/execute-cursor/approval-state.js +233 -17
- package/dist/activities/execute-cursor/approval-state.js.map +1 -1
- package/dist/activities/execute-cursor/capture-flow.d.ts +127 -0
- package/dist/activities/execute-cursor/capture-flow.js +234 -0
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -0
- package/dist/activities/execute-cursor/cas-observations.d.ts +96 -0
- package/dist/activities/execute-cursor/cas-observations.js +184 -0
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -0
- package/dist/activities/execute-cursor/command-provenance.d.ts +62 -0
- package/dist/activities/execute-cursor/command-provenance.js +128 -0
- package/dist/activities/execute-cursor/command-provenance.js.map +1 -0
- package/dist/activities/execute-cursor/exact-apply.d.ts +110 -0
- package/dist/activities/execute-cursor/exact-apply.js +204 -0
- package/dist/activities/execute-cursor/exact-apply.js.map +1 -0
- package/dist/activities/execute-cursor/hook-script.d.ts +53 -24
- package/dist/activities/execute-cursor/hook-script.js +310 -47
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/index.d.ts +8 -1
- package/dist/activities/execute-cursor/index.js +500 -64
- package/dist/activities/execute-cursor/index.js.map +1 -1
- package/dist/activities/execute-cursor/message-translator.d.ts +258 -11
- package/dist/activities/execute-cursor/message-translator.js +836 -90
- package/dist/activities/execute-cursor/message-translator.js.map +1 -1
- package/dist/activities/execute-cursor/prompt-builder.d.ts +20 -23
- package/dist/activities/execute-cursor/prompt-builder.js +74 -15
- package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
- package/dist/activities/execute-cursor/workspace-setup.d.ts +17 -0
- package/dist/activities/execute-cursor/workspace-setup.js +212 -33
- package/dist/activities/execute-cursor/workspace-setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.d.ts +19 -0
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js +143 -0
- package/dist/activities/execute-deep-agent/__test-utils__/gateway-substrate.js.map +1 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +88 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +81 -0
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -0
- package/dist/activities/execute-deep-agent/approval-file-change.d.ts +47 -0
- package/dist/activities/execute-deep-agent/approval-file-change.js +68 -0
- package/dist/activities/execute-deep-agent/approval-file-change.js.map +1 -0
- package/dist/activities/execute-deep-agent/attachment-injector.d.ts +8 -1
- package/dist/activities/execute-deep-agent/attachment-injector.js +7 -7
- package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +42 -0
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +47 -0
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.d.ts +79 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.js +112 -0
- package/dist/activities/execute-deep-agent/cas-capture-observer.js.map +1 -0
- package/dist/activities/execute-deep-agent/hitl.d.ts +16 -3
- package/dist/activities/execute-deep-agent/hitl.js +11 -3
- package/dist/activities/execute-deep-agent/hitl.js.map +1 -1
- package/dist/activities/execute-deep-agent/index.d.ts +2 -1
- package/dist/activities/execute-deep-agent/index.js +393 -33
- package/dist/activities/execute-deep-agent/index.js.map +1 -1
- package/dist/activities/execute-deep-agent/inline-publisher.d.ts +7 -1
- package/dist/activities/execute-deep-agent/inline-publisher.js +23 -2
- package/dist/activities/execute-deep-agent/inline-publisher.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.d.ts +53 -2
- package/dist/activities/execute-deep-agent/setup.js +149 -92
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.d.ts +36 -0
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.js +56 -0
- package/dist/activities/execute-deep-agent/stamp-flowed-rows.js.map +1 -0
- package/dist/activities/execute-deep-agent/status-builder-shared.d.ts +34 -1
- package/dist/activities/execute-deep-agent/status-builder-shared.js +26 -25
- package/dist/activities/execute-deep-agent/status-builder-shared.js.map +1 -1
- package/dist/activities/execute-deep-agent/status-builder.d.ts +17 -5
- package/dist/activities/execute-deep-agent/status-builder.js +44 -3
- package/dist/activities/execute-deep-agent/status-builder.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming-side-effects.js +2 -19
- package/dist/activities/execute-deep-agent/streaming-side-effects.js.map +1 -1
- package/dist/activities/execute-deep-agent/streaming.js +3 -15
- package/dist/activities/execute-deep-agent/streaming.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +25 -7
- package/dist/activities/execute-deep-agent/subagent-transformer.js +23 -7
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +30 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js +29 -3
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/execute-deep-agent/v3-status-builder.js +32 -2
- package/dist/activities/execute-deep-agent/v3-status-builder.js.map +1 -1
- package/dist/claimcheck/payload-codec.js +9 -5
- package/dist/claimcheck/payload-codec.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +2 -0
- package/dist/client/stigmer-client.js +2 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +85 -4
- package/dist/middleware/approval-gate.js +165 -38
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/types.d.ts +2 -5
- package/dist/shared/activity-input.d.ts +43 -0
- package/dist/shared/activity-input.js +17 -0
- package/dist/shared/activity-input.js.map +1 -0
- package/dist/shared/approval-canonicalize.d.ts +19 -0
- package/dist/shared/approval-canonicalize.js +119 -0
- package/dist/shared/approval-canonicalize.js.map +1 -0
- package/dist/shared/approval-fingerprint.d.ts +106 -0
- package/dist/shared/approval-fingerprint.js +113 -0
- package/dist/shared/approval-fingerprint.js.map +1 -0
- package/dist/shared/approval-policy.d.ts +182 -12
- package/dist/shared/approval-policy.js +213 -27
- package/dist/shared/approval-policy.js.map +1 -1
- package/dist/shared/args-preview.d.ts +52 -0
- package/dist/shared/args-preview.js +93 -0
- package/dist/shared/args-preview.js.map +1 -0
- package/dist/shared/artifact-storage.d.ts +19 -1
- package/dist/shared/artifact-storage.js +48 -11
- package/dist/shared/artifact-storage.js.map +1 -1
- package/dist/shared/file-change.d.ts +44 -0
- package/dist/shared/file-change.js +57 -0
- package/dist/shared/file-change.js.map +1 -0
- package/dist/shared/file-tools.d.ts +107 -0
- package/dist/shared/file-tools.js +168 -0
- package/dist/shared/file-tools.js.map +1 -0
- package/dist/shared/filereview/capture.d.ts +202 -0
- package/dist/shared/filereview/capture.js +498 -0
- package/dist/shared/filereview/capture.js.map +1 -0
- package/dist/shared/filereview/cas-substrate.d.ts +190 -0
- package/dist/shared/filereview/cas-substrate.js +284 -0
- package/dist/shared/filereview/cas-substrate.js.map +1 -0
- package/dist/shared/filereview/digest.d.ts +40 -0
- package/dist/shared/filereview/digest.js +66 -0
- package/dist/shared/filereview/digest.js.map +1 -0
- package/dist/shared/filereview/events.d.ts +170 -0
- package/dist/shared/filereview/events.js +298 -0
- package/dist/shared/filereview/events.js.map +1 -0
- package/dist/shared/filereview/git-substrate.d.ts +175 -0
- package/dist/shared/filereview/git-substrate.js +439 -0
- package/dist/shared/filereview/git-substrate.js.map +1 -0
- package/dist/shared/filereview/index.d.ts +11 -0
- package/dist/shared/filereview/index.js +12 -0
- package/dist/shared/filereview/index.js.map +1 -0
- package/dist/shared/filereview/secret-paths.d.ts +63 -0
- package/dist/shared/filereview/secret-paths.js +105 -0
- package/dist/shared/filereview/secret-paths.js.map +1 -0
- package/dist/shared/fingerprint-secret.d.ts +26 -0
- package/dist/shared/fingerprint-secret.js +47 -0
- package/dist/shared/fingerprint-secret.js.map +1 -0
- package/dist/shared/model-client.d.ts +51 -0
- package/dist/shared/model-client.js +77 -0
- package/dist/shared/model-client.js.map +1 -0
- package/dist/shared/plan-artifact.js +0 -2
- package/dist/shared/plan-artifact.js.map +1 -1
- package/dist/shared/status-offload.d.ts +83 -9
- package/dist/shared/status-offload.js +399 -79
- package/dist/shared/status-offload.js.map +1 -1
- package/dist/shared/status.js +14 -1
- package/dist/shared/status.js.map +1 -1
- package/dist/shared/tool-kind.d.ts +19 -0
- package/dist/shared/tool-kind.js +13 -0
- package/dist/shared/tool-kind.js.map +1 -1
- package/dist/shared/tool-row.d.ts +88 -0
- package/dist/shared/tool-row.js +127 -0
- package/dist/shared/tool-row.js.map +1 -0
- package/dist/shared/workspace/platform-dir.d.ts +25 -0
- package/dist/shared/workspace/platform-dir.js +38 -2
- package/dist/shared/workspace/platform-dir.js.map +1 -1
- package/dist/workflows/call-agent-orchestrator.js +56 -7
- package/dist/workflows/call-agent-orchestrator.js.map +1 -1
- package/dist/workflows/connect-mcp-server.d.ts +50 -0
- package/dist/workflows/connect-mcp-server.js +136 -15
- package/dist/workflows/connect-mcp-server.js.map +1 -1
- package/dist/workflows/types.d.ts +8 -0
- package/package.json +2 -2
- package/src/__test-utils__/approval-contract/contract.ts +224 -0
- package/src/__test-utils__/approval-contract/types.ts +179 -0
- package/src/__test-utils__/fake-artifact-storage.ts +72 -0
- package/src/__tests__/approval-gateway-contract.test.ts +29 -0
- package/src/__tests__/claimcheck-codec.test.ts +16 -53
- package/src/__tests__/golden-e2e.test.ts +2 -0
- package/src/__tests__/runner-token-coordinator.test.ts +3 -3
- package/src/activities/__tests__/call-agent-status.test.ts +135 -0
- package/src/activities/__tests__/call-llm.test.ts +1 -1
- package/src/activities/__tests__/classify-tool-approvals.test.ts +208 -1
- package/src/activities/__tests__/discover-mcp-server.test.ts +30 -0
- package/src/activities/__tests__/workflow-event-activities.test.ts +2 -1
- package/src/activities/call-agent-status.ts +74 -4
- package/src/activities/call-llm.ts +18 -63
- package/src/activities/classify-tool-approvals.ts +101 -19
- package/src/activities/discover-mcp-server.ts +29 -1
- package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +216 -0
- package/src/activities/execute-cursor/__test-utils__/gateway-substrate.ts +148 -0
- package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +41 -9
- package/src/activities/execute-cursor/__tests__/approval-state.test.ts +292 -0
- package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +68 -1
- package/src/activities/execute-cursor/__tests__/capture-flow.test.ts +1005 -0
- package/src/activities/execute-cursor/__tests__/cas-observations.test.ts +187 -0
- package/src/activities/execute-cursor/__tests__/coarse-fingerprint.test.ts +97 -0
- package/src/activities/execute-cursor/__tests__/command-provenance.test.ts +240 -0
- package/src/activities/execute-cursor/__tests__/deny-gate-exact-apply.test.ts +203 -0
- package/src/activities/execute-cursor/__tests__/exact-apply.test.ts +375 -0
- package/src/activities/execute-cursor/__tests__/hitl-ledger.test.ts +1294 -24
- package/src/activities/execute-cursor/__tests__/hitl-resume-history.test.ts +446 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +384 -110
- package/src/activities/execute-cursor/__tests__/message-translator.test.ts +171 -25
- package/src/activities/execute-cursor/__tests__/sequential-gate-resume.test.ts +189 -0
- package/src/activities/execute-cursor/__tests__/tool-result-image.test.ts +44 -23
- package/src/activities/execute-cursor/__tests__/workspace-setup.test.ts +190 -10
- package/src/activities/execute-cursor/approval-policy.ts +28 -159
- package/src/activities/execute-cursor/approval-state.ts +366 -18
- package/src/activities/execute-cursor/capture-flow.ts +323 -0
- package/src/activities/execute-cursor/cas-observations.ts +204 -0
- package/src/activities/execute-cursor/command-provenance.ts +168 -0
- package/src/activities/execute-cursor/exact-apply.ts +253 -0
- package/src/activities/execute-cursor/hook-script.ts +317 -51
- package/src/activities/execute-cursor/index.ts +575 -67
- package/src/activities/execute-cursor/message-translator.ts +963 -89
- package/src/activities/execute-cursor/prompt-builder.ts +80 -14
- package/src/activities/execute-cursor/workspace-setup.ts +257 -42
- package/src/activities/execute-deep-agent/__test-utils__/gateway-substrate.ts +180 -0
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +134 -0
- package/src/activities/execute-deep-agent/__tests__/approval-file-change.test.ts +84 -0
- package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +11 -24
- package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +64 -0
- package/src/activities/execute-deep-agent/__tests__/cas-capture-observer.test.ts +163 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-integration.test.ts +8 -5
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +342 -0
- package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +284 -0
- package/src/activities/execute-deep-agent/__tests__/hitl.test.ts +14 -36
- package/src/activities/execute-deep-agent/__tests__/inline-publisher.test.ts +31 -13
- package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +349 -0
- package/src/activities/execute-deep-agent/__tests__/stamp-flowed-rows.test.ts +119 -0
- package/src/activities/execute-deep-agent/__tests__/status-builder.test.ts +91 -10
- package/src/activities/execute-deep-agent/__tests__/streaming-v3.test.ts +9 -9
- package/src/activities/execute-deep-agent/__tests__/subagent-approval-propagation.test.ts +160 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-gitignored-capture.test.ts +213 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +3 -6
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +84 -1
- package/src/activities/execute-deep-agent/__tests__/v3-status-builder.test.ts +4 -1
- package/src/activities/execute-deep-agent/approval-file-change.ts +80 -0
- package/src/activities/execute-deep-agent/attachment-injector.ts +20 -11
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +66 -0
- package/src/activities/execute-deep-agent/cas-capture-observer.ts +125 -0
- package/src/activities/execute-deep-agent/hitl.ts +23 -7
- package/src/activities/execute-deep-agent/index.ts +463 -40
- package/src/activities/execute-deep-agent/inline-publisher.ts +27 -4
- package/src/activities/execute-deep-agent/setup.ts +223 -125
- package/src/activities/execute-deep-agent/stamp-flowed-rows.ts +64 -0
- package/src/activities/execute-deep-agent/status-builder-shared.ts +62 -23
- package/src/activities/execute-deep-agent/status-builder.ts +63 -8
- package/src/activities/execute-deep-agent/streaming-side-effects.ts +2 -16
- package/src/activities/execute-deep-agent/streaming.ts +3 -13
- package/src/activities/execute-deep-agent/subagent-transformer.ts +53 -13
- package/src/activities/execute-deep-agent/subagent-wiring.ts +50 -3
- package/src/activities/execute-deep-agent/v3-status-builder.ts +36 -2
- package/src/claimcheck/payload-codec.ts +8 -8
- package/src/client/stigmer-client.ts +9 -1
- package/src/middleware/__tests__/approval-gate.test.ts +488 -4
- package/src/middleware/approval-gate.ts +247 -38
- package/src/middleware/types.ts +5 -5
- package/src/shared/__tests__/activity-input.test.ts +78 -0
- package/src/shared/__tests__/approval-canonicalize.test.ts +106 -0
- package/src/shared/__tests__/approval-fingerprint.test.ts +115 -0
- package/src/shared/__tests__/approval-policy.test.ts +274 -40
- package/src/shared/__tests__/args-preview.test.ts +78 -0
- package/src/shared/__tests__/artifact-storage-extended.test.ts +62 -10
- package/src/shared/__tests__/artifact-storage.test.ts +123 -11
- package/src/shared/__tests__/file-change.test.ts +85 -0
- package/src/shared/__tests__/file-tools.test.ts +90 -0
- package/src/shared/__tests__/fingerprint-secret.test.ts +51 -0
- package/src/shared/__tests__/lease-scope-corpus.test.ts +56 -0
- package/src/shared/__tests__/model-client.test.ts +162 -0
- package/src/shared/__tests__/plan-artifact.test.ts +11 -26
- package/src/shared/__tests__/policy-source-corpus.test.ts +58 -0
- package/src/shared/__tests__/status-offload.test.ts +573 -16
- package/src/shared/__tests__/status.test.ts +4 -5
- package/src/shared/__tests__/tool-kind.test.ts +24 -1
- package/src/shared/__tests__/tool-row.test.ts +221 -0
- package/src/shared/activity-input.ts +57 -0
- package/src/shared/approval-canonicalize.ts +159 -0
- package/src/shared/approval-fingerprint.ts +148 -0
- package/src/shared/approval-policy.ts +303 -27
- package/src/shared/args-preview.ts +98 -0
- package/src/shared/artifact-storage.ts +62 -11
- package/src/shared/checkpointer/__tests__/http-saver.test.ts +1 -2
- package/src/shared/file-change.ts +64 -0
- package/src/shared/file-tools.ts +169 -0
- package/src/shared/filereview/__tests__/capture.test.ts +856 -0
- package/src/shared/filereview/__tests__/cas-substrate.test.ts +404 -0
- package/src/shared/filereview/__tests__/digest.test.ts +100 -0
- package/src/shared/filereview/__tests__/events.test.ts +245 -0
- package/src/shared/filereview/__tests__/git-substrate.test.ts +362 -0
- package/src/shared/filereview/__tests__/proxy-reconcile.test.ts +286 -0
- package/src/shared/filereview/__tests__/secret-paths.test.ts +121 -0
- package/src/shared/filereview/capture.ts +727 -0
- package/src/shared/filereview/cas-substrate.ts +401 -0
- package/src/shared/filereview/digest.ts +83 -0
- package/src/shared/filereview/events.ts +449 -0
- package/src/shared/filereview/git-substrate.ts +555 -0
- package/src/shared/filereview/index.ts +60 -0
- package/src/shared/filereview/secret-paths.ts +121 -0
- package/src/shared/fingerprint-secret.ts +53 -0
- package/src/shared/model-client.ts +122 -0
- package/src/shared/plan-artifact.ts +0 -2
- package/src/shared/status-offload.ts +433 -77
- package/src/shared/status.ts +13 -0
- package/src/shared/tool-kind.ts +33 -0
- package/src/shared/tool-row.ts +135 -0
- package/src/shared/workspace/platform-dir.ts +41 -2
- package/src/workflow-engine/__tests__/golden-execution.test.ts +35 -18
- package/src/workflow-engine/__tests__/tasks/try.test.ts +1 -1
- package/src/workflows/__tests__/connect-mcp-server.test.ts +304 -29
- package/src/workflows/call-agent-orchestrator.ts +53 -6
- package/src/workflows/connect-mcp-server.ts +179 -24
- package/src/workflows/types.ts +8 -0
|
@@ -31,17 +31,28 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
import { create } from "@bufbuild/protobuf";
|
|
34
|
+
import type { JsonObject } from "@bufbuild/protobuf";
|
|
34
35
|
import { AgentMessageSchema, ToolCallSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
35
36
|
import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
|
|
36
37
|
import { SubAgentExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
37
38
|
import type { SubAgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/subagent_pb";
|
|
38
|
-
import { MessageType, ToolCallStatus, SubAgentStatus } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
39
|
+
import { MessageType, ToolCallStatus, SubAgentStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
39
40
|
import type { SDKMessage } from "@cursor/sdk";
|
|
40
41
|
import type { MergedToolPolicy } from "./approval-policy.js";
|
|
41
|
-
import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage } from "./approval-policy.js";
|
|
42
|
-
import {
|
|
42
|
+
import { lookupMcpToolPolicy, resolveApprovalMessage, builtInRequiresApproval, getBuiltInApprovalMessage, SALIENT_ARG_FIELDS } from "./approval-policy.js";
|
|
43
|
+
import {
|
|
44
|
+
POLICY_ENGINE_VERSION,
|
|
45
|
+
resolveApprovalProvenance,
|
|
46
|
+
toProtoPolicySource,
|
|
47
|
+
} from "../../shared/approval-policy.js";
|
|
48
|
+
import { grantToken, primaryToken, toolIdentity, type DeniedLedgerEntry } from "./approval-state.js";
|
|
43
49
|
import { utcTimestamp } from "../../shared/status.js";
|
|
44
|
-
import {
|
|
50
|
+
import { hideToolCallRow, isToolCallRowHidden } from "../../shared/tool-row.js";
|
|
51
|
+
import { classifyTool, toolApprovalCategory, type ToolApprovalCategory } from "../../shared/tool-kind.js";
|
|
52
|
+
import { resolveWorkspacePath } from "../../shared/file-change.js";
|
|
53
|
+
import { contentDigest } from "../../shared/file-tools.js";
|
|
54
|
+
import { buildElidedArgsPreview } from "../../shared/args-preview.js";
|
|
55
|
+
import type { WorkspaceBackend } from "../../shared/workspace/types.js";
|
|
45
56
|
|
|
46
57
|
export { utcTimestamp };
|
|
47
58
|
|
|
@@ -163,10 +174,17 @@ function translateToolCall(event: Extract<SDKMessage, { type: "tool_call" }>): A
|
|
|
163
174
|
*
|
|
164
175
|
* Approval fields are populated when mergedPolicies are provided.
|
|
165
176
|
* Without policies, only basic fields are set (backward compatible).
|
|
177
|
+
*
|
|
178
|
+
* When mergedPolicies are provided, the tool call also carries its authorization
|
|
179
|
+
* provenance (approval_policy_source) — which policy layer gated or cleared it —
|
|
180
|
+
* derived from the same merged policy chain the gate uses, so the Cursor
|
|
181
|
+
* reconstruction is as auditable as the native harness. `provenance` supplies the
|
|
182
|
+
* run-scoped context (global bypass, active leases) the per-tool map cannot.
|
|
166
183
|
*/
|
|
167
184
|
export function buildToolCallProto(
|
|
168
185
|
event: Extract<SDKMessage, { type: "tool_call" }>,
|
|
169
186
|
mergedPolicies?: Map<string, MergedToolPolicy>,
|
|
187
|
+
provenance?: ApprovalProvenanceContext,
|
|
170
188
|
): ToolCall {
|
|
171
189
|
const status = mapToolCallStatus(event.status);
|
|
172
190
|
const mcpDetails = extractMcpToolDetails(event);
|
|
@@ -231,9 +249,38 @@ export function buildToolCallProto(
|
|
|
231
249
|
}
|
|
232
250
|
}
|
|
233
251
|
|
|
252
|
+
// Stamp authorization provenance from the same merged policy chain the gate
|
|
253
|
+
// (the deny-oracle hook + this map) uses, so the persisted record explains WHY
|
|
254
|
+
// each tool was gated or cleared. Only when policies are present — the stateless
|
|
255
|
+
// path leaves it UNSPECIFIED, like an unclassified tool_kind.
|
|
256
|
+
if (mergedPolicies) {
|
|
257
|
+
const source = resolveApprovalProvenance(
|
|
258
|
+
actualName,
|
|
259
|
+
mcpServerSlug,
|
|
260
|
+
mergedPolicies,
|
|
261
|
+
provenance?.leasedCategories ?? NO_LEASED_CATEGORIES,
|
|
262
|
+
provenance?.globalBypass ?? false,
|
|
263
|
+
);
|
|
264
|
+
toolCall.approvalPolicySource = toProtoPolicySource(source);
|
|
265
|
+
if (source) toolCall.policyEngineVersion = POLICY_ENGINE_VERSION;
|
|
266
|
+
}
|
|
267
|
+
|
|
234
268
|
return toolCall;
|
|
235
269
|
}
|
|
236
270
|
|
|
271
|
+
/**
|
|
272
|
+
* Run-scoped approval context the Cursor reconstruction needs to attribute a tool
|
|
273
|
+
* call's provenance beyond the per-tool merged policy map: the pre-armed global
|
|
274
|
+
* bypass and the built-in categories holding a run-lifetime lease.
|
|
275
|
+
*/
|
|
276
|
+
export interface ApprovalProvenanceContext {
|
|
277
|
+
readonly globalBypass: boolean;
|
|
278
|
+
readonly leasedCategories: ReadonlySet<ToolApprovalCategory>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Shared empty set so a reconstruction without leases allocates nothing. */
|
|
282
|
+
const NO_LEASED_CATEGORIES: ReadonlySet<ToolApprovalCategory> = new Set();
|
|
283
|
+
|
|
237
284
|
function translateTask(event: Extract<SDKMessage, { type: "task" }>): AgentMessage {
|
|
238
285
|
return create(AgentMessageSchema, {
|
|
239
286
|
type: MessageType.MESSAGE_SYSTEM,
|
|
@@ -432,14 +479,25 @@ function blockText(b: Record<string, unknown>): string | undefined {
|
|
|
432
479
|
*
|
|
433
480
|
* The Cursor SDK returns sub-agent work as a blob in the task tool's
|
|
434
481
|
* completed event (not as streaming events with a distinct agent_id).
|
|
482
|
+
* Re-verified 2026-07-02 with live recordings on both the pinned SDK (1.0.13)
|
|
483
|
+
* and the latest (1.0.22): zero events reach the parent's run.stream() between
|
|
484
|
+
* the task tool's "running" and "completed" events, every event carries the
|
|
485
|
+
* parent's agent_id, and the child agentId visible in the task args at spawn
|
|
486
|
+
* is NOT queryable mid-run through any public read surface (Agent.listRuns /
|
|
487
|
+
* Agent.messages.list / Agent.getRun all return not-found for it; the SDK's
|
|
488
|
+
* on-disk sub-agent transcript is written only at completion). Live nested
|
|
489
|
+
* visibility is therefore an upstream SDK limitation — do not try to fake it
|
|
490
|
+
* here; the UI shows an elapsed-time affordance instead (SubAgentSection).
|
|
435
491
|
* The result shape is:
|
|
436
492
|
*
|
|
437
493
|
* { status: "success", value: { conversationSteps: ConversationStep[] } }
|
|
438
494
|
*
|
|
439
|
-
* where ConversationStep is a
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* - {
|
|
495
|
+
* where each ConversationStep is a protobuf-oneof object keyed DIRECTLY by its
|
|
496
|
+
* kind (there is NO `{ type, message }` envelope — verified against production
|
|
497
|
+
* sub-agent blobs; see the `buildSubAgentToolCall` note):
|
|
498
|
+
* - { thinkingMessage: { text, thinkingDurationMs? } }
|
|
499
|
+
* - { assistantMessage: { text } }
|
|
500
|
+
* - { toolCall: { toolCallId, <kind>ToolCall: { args, result } } }
|
|
443
501
|
*
|
|
444
502
|
* This function defensively parses whatever steps are present and
|
|
445
503
|
* appends corresponding AgentMessage protos to the output array.
|
|
@@ -484,56 +542,172 @@ export function extractConversationSteps(
|
|
|
484
542
|
timestamp: utcTimestamp(),
|
|
485
543
|
}));
|
|
486
544
|
}
|
|
487
|
-
} else if (
|
|
488
|
-
const
|
|
489
|
-
if (
|
|
490
|
-
|
|
491
|
-
const toolArgs = msg.args != null ? JSON.stringify(msg.args) : "";
|
|
492
|
-
let toolResult = "";
|
|
493
|
-
if (msg.result != null) {
|
|
494
|
-
const resultObj = msg.result as Record<string, unknown>;
|
|
495
|
-
if (resultObj.status === "success" && resultObj.value != null) {
|
|
496
|
-
// Normalize a sub-agent screenshot the same way as a top-level tool
|
|
497
|
-
// result; fall back to the existing value serialization otherwise.
|
|
498
|
-
toolResult = canonicalizeImageResult(resultObj.value)
|
|
499
|
-
?? (typeof resultObj.value === "string"
|
|
500
|
-
? resultObj.value
|
|
501
|
-
: JSON.stringify(resultObj.value));
|
|
502
|
-
} else if (resultObj.status === "error") {
|
|
503
|
-
toolResult = typeof resultObj.error === "string"
|
|
504
|
-
? resultObj.error
|
|
505
|
-
: JSON.stringify(resultObj);
|
|
506
|
-
} else {
|
|
507
|
-
toolResult = JSON.stringify(msg.result);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
const aiMsg = create(AgentMessageSchema, {
|
|
545
|
+
} else if (s.toolCall != null) {
|
|
546
|
+
const tc = buildSubAgentToolCall(s.toolCall, out.length);
|
|
547
|
+
if (tc) {
|
|
548
|
+
out.push(create(AgentMessageSchema, {
|
|
512
549
|
type: MessageType.MESSAGE_AI,
|
|
513
550
|
content: "",
|
|
514
551
|
timestamp: utcTimestamp(),
|
|
515
|
-
toolCalls: [
|
|
516
|
-
|
|
517
|
-
name: toolName,
|
|
518
|
-
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
519
|
-
argsPreview: toolArgs,
|
|
520
|
-
result: toolResult,
|
|
521
|
-
startedAt: utcTimestamp(),
|
|
522
|
-
completedAt: utcTimestamp(),
|
|
523
|
-
toolKind: classifyTool(toolName),
|
|
524
|
-
})],
|
|
525
|
-
});
|
|
526
|
-
out.push(aiMsg);
|
|
552
|
+
toolCalls: [tc],
|
|
553
|
+
}));
|
|
527
554
|
}
|
|
528
555
|
}
|
|
529
556
|
}
|
|
530
557
|
}
|
|
531
558
|
|
|
559
|
+
/**
|
|
560
|
+
* The failure branches of a sub-agent tool call's `result` oneof. A completion
|
|
561
|
+
* is `{ success: ... }`; every other branch is a non-completion the UI must show
|
|
562
|
+
* as failed — an errored read/glob/grep (`error`), or a shell the approval gate
|
|
563
|
+
* stopped (`permissionDenied` / `rejected`).
|
|
564
|
+
*/
|
|
565
|
+
const SUBAGENT_TOOL_RESULT_FAILURE_KEYS = new Set([
|
|
566
|
+
"error",
|
|
567
|
+
"permissionDenied",
|
|
568
|
+
"rejected",
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Build a ToolCall proto from one sub-agent `toolCall` conversation step.
|
|
573
|
+
*
|
|
574
|
+
* The Cursor SDK serializes a sub-agent's tool call as protobuf-oneof JSON:
|
|
575
|
+
*
|
|
576
|
+
* { toolCallId, <kind>ToolCall: { args, result } }
|
|
577
|
+
*
|
|
578
|
+
* The tool family is the lone `<kind>ToolCall` sibling of `toolCallId` (e.g.
|
|
579
|
+
* `readToolCall`, `globToolCall`, `grepToolCall`, `shellToolCall`); the bare
|
|
580
|
+
* tool name (`read`) is the suffix-stripped key, which feeds the shared
|
|
581
|
+
* {@link classifyTool} exactly like a top-level call. `result` is itself a oneof
|
|
582
|
+
* `{ success | error | permissionDenied | rejected }` (see
|
|
583
|
+
* {@link interpretSubAgentToolResult}).
|
|
584
|
+
*
|
|
585
|
+
* This is deliberately key-driven rather than an enumerated switch, so a new
|
|
586
|
+
* tool family the SDK adds surfaces automatically instead of being dropped.
|
|
587
|
+
* Returns undefined when no `<kind>ToolCall` key is present (a malformed or
|
|
588
|
+
* forward-incompatible step), so the caller skips it rather than emitting a
|
|
589
|
+
* blank, nameless tool call.
|
|
590
|
+
*
|
|
591
|
+
* History: an earlier revision parsed a `{ type: "toolCall", message: { type,
|
|
592
|
+
* args, result: { status, value } } }` envelope. That shape never appears in the
|
|
593
|
+
* real task-result blob (confirmed against production sub-agent outputs and the
|
|
594
|
+
* WA03 capture), so every sub-agent tool call was silently discarded and the UI
|
|
595
|
+
* showed a sub-agent that "did nothing".
|
|
596
|
+
*/
|
|
597
|
+
function buildSubAgentToolCall(
|
|
598
|
+
toolCall: unknown,
|
|
599
|
+
seq: number,
|
|
600
|
+
): ToolCall | undefined {
|
|
601
|
+
if (toolCall == null || typeof toolCall !== "object") return undefined;
|
|
602
|
+
const obj = toolCall as Record<string, unknown>;
|
|
603
|
+
|
|
604
|
+
const kindKey = Object.keys(obj).find(
|
|
605
|
+
(k) => k !== "toolCallId" && k.endsWith("ToolCall"),
|
|
606
|
+
);
|
|
607
|
+
if (!kindKey) return undefined;
|
|
608
|
+
|
|
609
|
+
const name = kindKey.slice(0, -"ToolCall".length);
|
|
610
|
+
const inner =
|
|
611
|
+
obj[kindKey] != null && typeof obj[kindKey] === "object"
|
|
612
|
+
? (obj[kindKey] as Record<string, unknown>)
|
|
613
|
+
: {};
|
|
614
|
+
// Prefer the SDK's real call id so the row is stable across resumes and never
|
|
615
|
+
// collides with a sibling; fall back to a per-step synthetic id only when the
|
|
616
|
+
// SDK omits one.
|
|
617
|
+
const id =
|
|
618
|
+
typeof obj.toolCallId === "string" && obj.toolCallId
|
|
619
|
+
? obj.toolCallId
|
|
620
|
+
: `sub-${name}-${seq}`;
|
|
621
|
+
|
|
622
|
+
const { status, result, error } = interpretSubAgentToolResult(inner.result);
|
|
623
|
+
|
|
624
|
+
const tc = create(ToolCallSchema, {
|
|
625
|
+
id,
|
|
626
|
+
name,
|
|
627
|
+
status,
|
|
628
|
+
result,
|
|
629
|
+
error,
|
|
630
|
+
startedAt: utcTimestamp(),
|
|
631
|
+
completedAt: utcTimestamp(),
|
|
632
|
+
toolKind: classifyTool(name),
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
if (inner.args != null && typeof inner.args === "object") {
|
|
636
|
+
tc.args = inner.args as JsonObject;
|
|
637
|
+
tc.argsPreview = JSON.stringify(inner.args);
|
|
638
|
+
}
|
|
639
|
+
return tc;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Map a sub-agent tool call's `result` oneof to a (status, result, error)
|
|
644
|
+
* triple. `success` → COMPLETED with the serialized payload (a screenshot is
|
|
645
|
+
* canonicalized the same way as a top-level result); any failure branch (see
|
|
646
|
+
* {@link SUBAGENT_TOOL_RESULT_FAILURE_KEYS}) → FAILED with the serialized
|
|
647
|
+
* detail. An absent result is a COMPLETED call with no output — the SDK omits
|
|
648
|
+
* `result` for a call that reports nothing.
|
|
649
|
+
*/
|
|
650
|
+
function interpretSubAgentToolResult(result: unknown): {
|
|
651
|
+
status: ToolCallStatus;
|
|
652
|
+
result: string;
|
|
653
|
+
error: string;
|
|
654
|
+
} {
|
|
655
|
+
if (result == null || typeof result !== "object") {
|
|
656
|
+
return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: "", error: "" };
|
|
657
|
+
}
|
|
658
|
+
const r = result as Record<string, unknown>;
|
|
659
|
+
|
|
660
|
+
if ("success" in r) {
|
|
661
|
+
const val = r.success;
|
|
662
|
+
const str =
|
|
663
|
+
canonicalizeImageResult(val) ??
|
|
664
|
+
(typeof val === "string" ? val : JSON.stringify(val));
|
|
665
|
+
return { status: ToolCallStatus.TOOL_CALL_COMPLETED, result: str, error: "" };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const failKey = Object.keys(r).find((k) =>
|
|
669
|
+
SUBAGENT_TOOL_RESULT_FAILURE_KEYS.has(k),
|
|
670
|
+
);
|
|
671
|
+
if (failKey) {
|
|
672
|
+
const val = r[failKey];
|
|
673
|
+
const detail = typeof val === "string" ? val : JSON.stringify(val);
|
|
674
|
+
return { status: ToolCallStatus.TOOL_CALL_FAILED, result: "", error: detail };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Unknown oneof branch — surface it as a completed result rather than drop it.
|
|
678
|
+
return {
|
|
679
|
+
status: ToolCallStatus.TOOL_CALL_COMPLETED,
|
|
680
|
+
result: JSON.stringify(r),
|
|
681
|
+
error: "",
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
532
685
|
/**
|
|
533
686
|
* Options for creating a MessageAccumulator with policy awareness.
|
|
534
687
|
*/
|
|
535
688
|
export interface MessageAccumulatorOptions {
|
|
536
689
|
mergedPolicies?: Map<string, MergedToolPolicy>;
|
|
690
|
+
/**
|
|
691
|
+
* Run-scoped approval context (global bypass + active leases) so reconstructed
|
|
692
|
+
* tool calls carry their authorization provenance. Omitted in unit tests that
|
|
693
|
+
* only assert basic translation; provenance then stays UNSPECIFIED.
|
|
694
|
+
*/
|
|
695
|
+
provenance?: ApprovalProvenanceContext;
|
|
696
|
+
/**
|
|
697
|
+
* Absolute workspace root, used to render file-change paths relative to the
|
|
698
|
+
* workspace (with the absolute path retained). Omitted in unit tests, in
|
|
699
|
+
* which case raw tool-arg paths are used verbatim.
|
|
700
|
+
*/
|
|
701
|
+
workspaceRoot?: string;
|
|
702
|
+
/**
|
|
703
|
+
* Sub-agent executions carried over from the persisted transcript on a
|
|
704
|
+
* durable resume (see seedCursorTranscriptFromExecution in index.ts). The
|
|
705
|
+
* accumulator re-registers them so a sub-agent's resumed lifecycle updates
|
|
706
|
+
* merge onto the seeded row instead of producing a duplicate, and so the row
|
|
707
|
+
* survives the round-trip rather than being dropped from the rebuilt status.
|
|
708
|
+
* Empty on a first run.
|
|
709
|
+
*/
|
|
710
|
+
seededSubAgents?: SubAgentExecution[];
|
|
537
711
|
}
|
|
538
712
|
|
|
539
713
|
/**
|
|
@@ -596,12 +770,44 @@ export class MessageAccumulator {
|
|
|
596
770
|
private readonly _subAgentExecutions: SubAgentExecution[] = [];
|
|
597
771
|
private readonly subAgentMap = new Map<string, SubAgentExecution>();
|
|
598
772
|
private readonly mergedPolicies?: Map<string, MergedToolPolicy>;
|
|
773
|
+
private readonly provenance?: ApprovalProvenanceContext;
|
|
774
|
+
private readonly workspaceRoot?: string;
|
|
599
775
|
private readonly toolCallIndex = new Map<string, ToolCall>();
|
|
600
776
|
private _dirty = false;
|
|
601
777
|
|
|
602
778
|
constructor(messages: AgentMessage[], options?: MessageAccumulatorOptions) {
|
|
603
779
|
this.messages = messages;
|
|
604
780
|
this.mergedPolicies = options?.mergedPolicies;
|
|
781
|
+
this.provenance = options?.provenance;
|
|
782
|
+
this.workspaceRoot = options?.workspaceRoot;
|
|
783
|
+
|
|
784
|
+
// Resume seeding. When constructed over a pre-seeded transcript (a durable
|
|
785
|
+
// resume — see seedCursorTranscriptFromExecution in index.ts), rebuild the
|
|
786
|
+
// by-id tool-call index so a cross-message completion for a seeded call
|
|
787
|
+
// resolves onto the existing proto, and re-register seeded sub-agents so
|
|
788
|
+
// their resumed lifecycle updates merge in place. A first run carries an
|
|
789
|
+
// empty transcript and no seed, so both are no-ops. Mirrors the deep-agent
|
|
790
|
+
// ExecutionState.rebuildToolCallIndex + sub-agent re-registration on resume.
|
|
791
|
+
this.rebuildToolCallIndex();
|
|
792
|
+
for (const sub of options?.seededSubAgents ?? []) {
|
|
793
|
+
this._subAgentExecutions.push(sub);
|
|
794
|
+
if (sub.id) this.subAgentMap.set(sub.id, sub);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Index every tool call already present in the (seeded) transcript by its
|
|
800
|
+
* call_id. Called once at construction: on a first run the transcript is empty
|
|
801
|
+
* (no-op); on a resume it lets re-emitted lifecycle events for a previously
|
|
802
|
+
* committed call_id reconcile onto the existing proto instead of duplicating.
|
|
803
|
+
*/
|
|
804
|
+
private rebuildToolCallIndex(): void {
|
|
805
|
+
this.toolCallIndex.clear();
|
|
806
|
+
for (const message of this.messages) {
|
|
807
|
+
for (const tc of message.toolCalls) {
|
|
808
|
+
if (tc.id) this.toolCallIndex.set(tc.id, tc);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
605
811
|
}
|
|
606
812
|
|
|
607
813
|
get subAgentExecutions(): SubAgentExecution[] {
|
|
@@ -696,17 +902,62 @@ export class MessageAccumulator {
|
|
|
696
902
|
if (SUPPRESSED_TOOL_NAMES.has(event.name)) return;
|
|
697
903
|
|
|
698
904
|
const existing = this.toolCallIndex.get(event.call_id);
|
|
699
|
-
if (
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
905
|
+
if (existing) {
|
|
906
|
+
this.mergeToolCallEvent(existing, event);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const tc = buildToolCallProto(event, this.mergedPolicies, this.provenance);
|
|
911
|
+
|
|
912
|
+
// Resume reconciliation. A resumed Cursor agent re-runs a previously
|
|
913
|
+
// approved tool with a BRAND-NEW call_id, so it misses the by-id index
|
|
914
|
+
// above. Reconcile it onto the seeded WAITING_APPROVAL call with the same
|
|
915
|
+
// canonical identity (the (category, salient)/MCP-name space the hook and
|
|
916
|
+
// grants already use — see toolCallIdentityToken) and keep the original id.
|
|
917
|
+
// Without this the seeded approved call and the re-run would both appear (a
|
|
918
|
+
// duplicate row) and dropping the seeded id would trip the backend's
|
|
919
|
+
// append-only-at-identity guard, stalling the run. This generalizes the v2
|
|
920
|
+
// deep-agent StatusBuilder.findResumableSeededToolCall (a tool-name match)
|
|
921
|
+
// to the full Cursor identity, reusing the single existing identity
|
|
922
|
+
// definition rather than introducing a parallel one.
|
|
923
|
+
const seeded = this.findResumableSeededToolCall(tc);
|
|
924
|
+
if (seeded) {
|
|
925
|
+
// Re-key the fresh call_id onto the seeded proto so this call_id's later
|
|
926
|
+
// lifecycle events resolve here, then merge in place. mergeToolCallEvent
|
|
927
|
+
// advances WAITING_APPROVAL (non-terminal) toward the event's status.
|
|
928
|
+
this.toolCallIndex.set(event.call_id, seeded);
|
|
929
|
+
this.mergeToolCallEvent(seeded, event);
|
|
706
930
|
return;
|
|
707
931
|
}
|
|
708
932
|
|
|
709
|
-
this.
|
|
933
|
+
this.findOrCreateLastAiMessage().toolCalls.push(tc);
|
|
934
|
+
this.toolCallIndex.set(event.call_id, tc);
|
|
935
|
+
// A new tool call is a discrete, user-visible event — force a prompt
|
|
936
|
+
// flush so the live UI surfaces it the instant it starts.
|
|
937
|
+
this._dirty = true;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Find a seeded, still-gated tool call this resumed event should reconcile
|
|
942
|
+
* onto: the first tool call in the index that is still WAITING_APPROVAL and
|
|
943
|
+
* shares the candidate's canonical identity token. "First" (Map iteration =
|
|
944
|
+
* transcript order) mirrors the v2 deep-agent's ordered first-unreconciled
|
|
945
|
+
* match — once reconciled a call leaves WAITING_APPROVAL, so a second co-
|
|
946
|
+
* pending call with the same identity naturally reconciles onto the next one.
|
|
947
|
+
* Tool calls created during this turn are not WAITING_APPROVAL until the
|
|
948
|
+
* post-stream denial reconciliation runs, so they can never be matched here.
|
|
949
|
+
*/
|
|
950
|
+
private findResumableSeededToolCall(candidate: ToolCall): ToolCall | undefined {
|
|
951
|
+
const wanted = toolCallIdentityToken(candidate);
|
|
952
|
+
for (const tc of this.toolCallIndex.values()) {
|
|
953
|
+
if (
|
|
954
|
+
tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL &&
|
|
955
|
+
toolCallIdentityToken(tc) === wanted
|
|
956
|
+
) {
|
|
957
|
+
return tc;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return undefined;
|
|
710
961
|
}
|
|
711
962
|
|
|
712
963
|
/**
|
|
@@ -902,76 +1153,682 @@ export class MessageAccumulator {
|
|
|
902
1153
|
* approved once should produce one approval regardless of how many times the
|
|
903
1154
|
* agent re-attempted it within the turn.
|
|
904
1155
|
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
1156
|
+
* ONE GATE PER TURN (deny-only clean pause). The Cursor harness can only gate by
|
|
1157
|
+
* the hook returning `deny`, which Cursor surfaces to the model as a tool
|
|
1158
|
+
* *failure* — so a blocked model frequently improvises a workaround (the
|
|
1159
|
+
* canonical case: a denied `edit notes.md` followed ~2.5s later by a
|
|
1160
|
+
* `shell: cat > notes.md`, in the SAME assistant message with no narration
|
|
1161
|
+
* between them — observed in production, exec aex_01kw4p0cqgk0j8vvxbs5t8gv59).
|
|
1162
|
+
* The first-denial stop (index.ts) tries to cancel the turn at that first
|
|
1163
|
+
* denial, but `run.cancel()` is async and races the SDK's auto-execution, so the
|
|
1164
|
+
* workaround can still stream and land a SECOND denial in the ledger. Two
|
|
1165
|
+
* denials of distinct identity would otherwise surface two approval cards for
|
|
1166
|
+
* one logical intent. Their identities differ (`write\nnotes.md` vs
|
|
1167
|
+
* `shell\ncat > notes.md`), so no same-identity twin collapse can join them, and
|
|
1168
|
+
* they share one message, so no positional rule can separate them; the only
|
|
1169
|
+
* honest signal that the second is a reaction is CAUSALITY — it was emitted
|
|
1170
|
+
* after the model saw the first denial. We therefore ANCHOR on the FIRST ledger
|
|
1171
|
+
* denial of the turn (the ledger is reset per turn and appended in denial order,
|
|
1172
|
+
* so ledger[0] is the original intent) and surface ONLY that identity. Every
|
|
1173
|
+
* other denied identity in the turn — a post-denial workaround, or a genuine
|
|
1174
|
+
* co-pending sibling the deny-only harness defers — is blanked in place to a
|
|
1175
|
+
* hidden SKIPPED row ({@link collapseNonAnchorDenials}). A deferred sibling is
|
|
1176
|
+
* not lost: on resume it re-attempts and gates again next turn (sequential
|
|
1177
|
+
* gating). The native (LangGraph) harness pauses BEFORE the model can react, so
|
|
1178
|
+
* it keeps full in-turn co-pending and is untouched by this rule. This is the
|
|
1179
|
+
* near-term, invariant-preserving stepping stone to the Tool Execution Gateway,
|
|
1180
|
+
* where an un-leased workaround is refused by construction.
|
|
1181
|
+
*
|
|
1182
|
+
* Correlation runs in two passes. The first matches the streamed token to a
|
|
1183
|
+
* ledger token byte-for-byte (the common case). The hook, however, records its
|
|
1184
|
+
* token from the RAW path Cursor hands it — a bash script cannot normalize a
|
|
1185
|
+
* path against the workspace root — so an ABSOLUTE hook `file_path` against a
|
|
1186
|
+
* RELATIVE stream `path` (or vice versa) yields two different raw tokens for one
|
|
1187
|
+
* edit and the exact pass misses. The runner CAN normalize, so a second pass
|
|
1188
|
+
* matches any still-unmatched FILE denial to a streamed call by (category,
|
|
1189
|
+
* workspace-normalized path) and overlays the REAL streamed call, never appending
|
|
1190
|
+
* a content-less placeholder beside it. This is the difference between one honest
|
|
1191
|
+
* gate and two cards, one of which reads "No preview available". It reuses the
|
|
1192
|
+
* single tool-identity definition + `resolveWorkspacePath`; it introduces no
|
|
1193
|
+
* parallel identity.
|
|
909
1194
|
*
|
|
910
|
-
*
|
|
1195
|
+
* Only after BOTH passes miss is a placeholder WAITING_APPROVAL tool call
|
|
1196
|
+
* synthesized (rare — Cursor normally emits a tool_call event for every
|
|
1197
|
+
* attempt), so the gate still surfaces and never renders as a silent success.
|
|
1198
|
+
* Critically, every match overlays a call IN PLACE (the committed id is
|
|
1199
|
+
* preserved): the backend's append-only-at-identity transcript guard rejects a
|
|
1200
|
+
* finalize that drops a previously-committed tool-call id, so reconciliation may
|
|
1201
|
+
* only reconcile entries in place, never remove them.
|
|
1202
|
+
*
|
|
1203
|
+
* Every matched/synthesized call is enriched with the hook-captured authoritative
|
|
1204
|
+
* input (`ledger.input`) via {@link applyGateInput}: the full proposed args, a
|
|
1205
|
+
* compact `args_preview`, and the content digest — so the approval card renders
|
|
1206
|
+
* the proposed write/edit content from `args` and a resume re-gates a diverging
|
|
1207
|
+
* sibling edit. A missing capture (the hook's grep fallback) degrades to the
|
|
1208
|
+
* prior behavior.
|
|
1209
|
+
*
|
|
1210
|
+
* Returns the tool calls now marked WAITING_APPROVAL — the single anchor gate
|
|
1211
|
+
* for the turn (overlaid or, rarely, synthesized).
|
|
911
1212
|
*/
|
|
912
|
-
export function reconcileDeniedToolCalls(
|
|
1213
|
+
export async function reconcileDeniedToolCalls(
|
|
913
1214
|
messages: AgentMessage[],
|
|
914
1215
|
ledger: DeniedLedgerEntry[],
|
|
915
1216
|
mergedPolicies?: Map<string, MergedToolPolicy>,
|
|
916
|
-
|
|
1217
|
+
workspaceBackend?: WorkspaceBackend,
|
|
1218
|
+
): Promise<ToolCall[]> {
|
|
917
1219
|
if (ledger.length === 0) return [];
|
|
918
1220
|
|
|
919
|
-
//
|
|
920
|
-
//
|
|
1221
|
+
// The workspace the gated files live in; its rootDir normalizes paths for the
|
|
1222
|
+
// abs-vs-rel correlation fallback (normalizedFileSalient).
|
|
1223
|
+
const workspaceRoot = workspaceBackend?.rootDir;
|
|
1224
|
+
|
|
1225
|
+
// One gate per turn: anchor on the FIRST ledger denial. The ledger is reset
|
|
1226
|
+
// per turn and appended in denial order, so ledger[0] is the model's original
|
|
1227
|
+
// intent; any later denial of a DIFFERENT identity is a post-denial workaround
|
|
1228
|
+
// or a deferred co-pending sibling (see the doc comment). We surface ONLY the
|
|
1229
|
+
// anchor identity below and blank every other denied identity to a hidden
|
|
1230
|
+
// SKIPPED row. `deniedTokens` still carries every denied identity — it is the
|
|
1231
|
+
// scope for that collapse, never an additional gate.
|
|
1232
|
+
const anchorToken = ledger[0].token;
|
|
921
1233
|
const deniedTokens = new Set(ledger.map((e) => e.token));
|
|
922
|
-
|
|
1234
|
+
// The authoritative pre-execution args the hook captured for the anchor. A
|
|
1235
|
+
// resource re-attempted within the turn shares a token; last write wins (the
|
|
1236
|
+
// attempts carry the same proposed change).
|
|
1237
|
+
let anchorInput: Record<string, unknown> | undefined;
|
|
1238
|
+
for (const e of ledger) {
|
|
1239
|
+
if (e.token === anchorToken && e.input) anchorInput = e.input;
|
|
1240
|
+
}
|
|
1241
|
+
const matchedCalls = new Set<ToolCall>();
|
|
923
1242
|
const result: ToolCall[] = [];
|
|
1243
|
+
let anchorMatched = false;
|
|
924
1244
|
|
|
925
|
-
// 1.
|
|
1245
|
+
// 1. Exact overlay: a streamed call whose token equals the anchor denial token
|
|
1246
|
+
// byte-for-byte (the path form agreed on both sides). The anchor resource
|
|
1247
|
+
// re-attempted within the turn shares one token and collapses to a single
|
|
1248
|
+
// approval (the first match is the keeper; same-identity twins are blanked
|
|
1249
|
+
// by collapseRedundantToolCallTwins below).
|
|
926
1250
|
for (const msg of messages) {
|
|
1251
|
+
if (anchorMatched) break;
|
|
927
1252
|
for (const tc of msg.toolCalls) {
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
matched.add(token);
|
|
1253
|
+
if (toolCallIdentityToken(tc) !== anchorToken) continue;
|
|
1254
|
+
overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
|
|
1255
|
+
matchedCalls.add(tc);
|
|
932
1256
|
result.push(tc);
|
|
1257
|
+
anchorMatched = true;
|
|
1258
|
+
break;
|
|
933
1259
|
}
|
|
934
1260
|
}
|
|
935
1261
|
|
|
936
|
-
// 2.
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1262
|
+
// 2. Normalized-path fallback (the abs-vs-rel drift fix): if the anchor is a
|
|
1263
|
+
// FILE denial the exact pass missed, match a streamed call by (category,
|
|
1264
|
+
// workspace-normalized path) and overlay the REAL call — never a content-
|
|
1265
|
+
// less placeholder beside it. Requires the workspace root to normalize;
|
|
1266
|
+
// shell/MCP denials (no path) and resumes without a root fall through to
|
|
1267
|
+
// synthesis.
|
|
1268
|
+
if (!anchorMatched && workspaceRoot) {
|
|
1269
|
+
const decoded = decodeIdentityToken(anchorToken);
|
|
1270
|
+
const wanted = decoded
|
|
1271
|
+
? normalizedFileSalient(decoded.key, decoded.salient, workspaceRoot)
|
|
1272
|
+
: undefined;
|
|
1273
|
+
if (wanted) {
|
|
1274
|
+
const tc = findUnmatchedStreamCallByNormalizedSalient(
|
|
1275
|
+
messages, matchedCalls, wanted, workspaceRoot,
|
|
1276
|
+
);
|
|
1277
|
+
if (tc) {
|
|
1278
|
+
overlayDeniedStreamCall(tc, anchorInput, mergedPolicies);
|
|
1279
|
+
matchedCalls.add(tc);
|
|
1280
|
+
result.push(tc);
|
|
1281
|
+
anchorMatched = true;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// 2a. One gate per turn: blank every denied identity OTHER than the anchor to a
|
|
1287
|
+
// hidden SKIPPED row (the workaround shell, or a deferred co-pending
|
|
1288
|
+
// sibling). Runs BEFORE the WAITING_FOR_APPROVAL persist so a reaction is
|
|
1289
|
+
// never persisted as WAITING_APPROVAL — the backend authors an approval
|
|
1290
|
+
// REQUESTED event only from a WAITING_APPROVAL tool call, so collapsing
|
|
1291
|
+
// here keeps the append-only approval-event stream free of an orphan
|
|
1292
|
+
// REQUESTED that would need retraction.
|
|
1293
|
+
const nonAnchorCollapsed = collapseNonAnchorDenials(messages, deniedTokens, anchorToken);
|
|
1294
|
+
if (nonAnchorCollapsed > 0) {
|
|
1295
|
+
console.log(
|
|
1296
|
+
`ExecuteCursor reconcile collapsed ${nonAnchorCollapsed} non-anchor denied ` +
|
|
1297
|
+
`tool call(s) to hidden SKIPPED (one gate per turn; anchor is the first ` +
|
|
1298
|
+
`denial of the turn)`,
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// 2b. Collapse same-turn duplicate edits. When the model emitted the SAME
|
|
1303
|
+
// resource twice in one turn (two call ids, one identity token), only the
|
|
1304
|
+
// FIRST same-token stream call was overlaid into the gate above; any OTHER
|
|
1305
|
+
// same-token call stays a committed row (RUNNING zombie, or a
|
|
1306
|
+
// denied-reported-as-success COMPLETED) that would render as a second,
|
|
1307
|
+
// content-less card beside the gate (the reported "No preview available"
|
|
1308
|
+
// duplicate). The overlaid gate is now WAITING_APPROVAL, so the shared
|
|
1309
|
+
// routine recognizes it as the keeper and blanks the twins IN PLACE to
|
|
1310
|
+
// hidden SKIPPED rows — we cannot drop them, since the backend's
|
|
1311
|
+
// append-only-at-identity guard rejects removing a previously-committed
|
|
1312
|
+
// tool-call id, but the id is preserved so the finalize stays append-only.
|
|
1313
|
+
const collapsed = collapseRedundantToolCallTwins(messages);
|
|
1314
|
+
if (collapsed > 0) {
|
|
1315
|
+
console.log(
|
|
1316
|
+
`ExecuteCursor reconcile collapsed ${collapsed} redundant tool-call twin(s) ` +
|
|
1317
|
+
`superseded by the approval gate (kept in place as hidden SKIPPED rows)`,
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// 2c. Finalize interrupted rows. The first-denial stop cancelled the run, so
|
|
1322
|
+
// a tool call still PENDING/RUNNING here can never complete — no event
|
|
1323
|
+
// will ever deliver its result, and left alone it persists as a spinner
|
|
1324
|
+
// forever. The canonical victim is a post-denial workaround whose own
|
|
1325
|
+
// hook denial raced (or never reached) the final ledger read, so the
|
|
1326
|
+
// token-scoped collapse in 2a could not see it (production case
|
|
1327
|
+
// aex_01kwj07f7g23c3wp9sn8496z5g: a python-write shell reaction persisted
|
|
1328
|
+
// as RUNNING with requiresApproval=true). Whatever the cause, a
|
|
1329
|
+
// non-terminal row on a turn that is pausing is an interrupted attempt
|
|
1330
|
+
// with no output: collapse it to the same hidden SKIPPED shape as every
|
|
1331
|
+
// other superseded row (in place — the append-only-at-identity guard
|
|
1332
|
+
// forbids dropping a committed id). Runs AFTER the anchor overlay, so the
|
|
1333
|
+
// gate itself (now WAITING_APPROVAL) is never touched.
|
|
1334
|
+
const interrupted = finalizeInterruptedToolCalls(messages);
|
|
1335
|
+
if (interrupted > 0) {
|
|
1336
|
+
console.log(
|
|
1337
|
+
`ExecuteCursor reconcile collapsed ${interrupted} interrupted non-terminal ` +
|
|
1338
|
+
`tool call(s) that can never complete (run cancelled at first denial)`,
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// 3. Synthesize the anchor gate if it matched NO streamed call in either pass
|
|
1343
|
+
// (rare — Cursor emits a tool_call event for every attempt), so the gate
|
|
1344
|
+
// still surfaces rather than rendering as a silent success. After the
|
|
1345
|
+
// normalized fallback this should be ~0; the caller logs a divergence when
|
|
1346
|
+
// it is not (a synthesized id is prefixed `approval:`). Only the anchor is
|
|
1347
|
+
// ever synthesized: non-anchor denials are deliberately collapsed, never
|
|
1348
|
+
// surfaced (one gate per turn).
|
|
1349
|
+
if (!anchorMatched) {
|
|
1350
|
+
const anchorEntry = ledger.find((e) => e.token === anchorToken) ?? ledger[0];
|
|
1351
|
+
const decoded = decodeIdentityToken(anchorToken);
|
|
943
1352
|
// Display the hook's raw tool name; carry the decoded salient so the grant
|
|
944
1353
|
// rebuilt from this tool call on reinvocation keys on the same resource.
|
|
945
|
-
const displayName =
|
|
1354
|
+
const displayName = anchorEntry.toolName || decoded?.key || "tool";
|
|
946
1355
|
const salient = decoded?.salient ?? "";
|
|
947
|
-
const tc = synthesizeWaitingApprovalToolCall(
|
|
1356
|
+
const tc = synthesizeWaitingApprovalToolCall(
|
|
1357
|
+
displayName, salient, decoded?.digest ?? "", anchorToken, mergedPolicies,
|
|
1358
|
+
);
|
|
1359
|
+
// The hook-captured input upgrades the placeholder from a bare {path} to the
|
|
1360
|
+
// full proposed args, so even a synthesized gate shows the proposed change.
|
|
1361
|
+
applyGateInput(tc, anchorInput ?? anchorEntry.input);
|
|
948
1362
|
appendToolCallToLastAiMessage(messages, tc);
|
|
949
|
-
matched.add(entry.token);
|
|
950
1363
|
result.push(tc);
|
|
951
1364
|
}
|
|
952
1365
|
|
|
953
1366
|
return result;
|
|
954
1367
|
}
|
|
955
1368
|
|
|
1369
|
+
/**
|
|
1370
|
+
* Overlay WAITING_APPROVAL onto a streamed tool call the hook denied. Mutates
|
|
1371
|
+
* `tc` in place — the call keeps its committed id, so the backend's
|
|
1372
|
+
* append-only-at-identity transcript guard accepts the finalize (an in-place
|
|
1373
|
+
* status change is a reconcile, not a drop). The single overlay routine for both
|
|
1374
|
+
* the exact and the normalized correlation passes, so the gate diff can never
|
|
1375
|
+
* diverge between them. The hook-captured `input` (when present) is the
|
|
1376
|
+
* authoritative, complete proposed args — the stream may have carried only
|
|
1377
|
+
* partial args before the first-denial cancel — so it supplies the args preview
|
|
1378
|
+
* and the content digest (see {@link applyGateInput}).
|
|
1379
|
+
*/
|
|
1380
|
+
function overlayDeniedStreamCall(
|
|
1381
|
+
tc: ToolCall,
|
|
1382
|
+
input: Record<string, unknown> | undefined,
|
|
1383
|
+
mergedPolicies: Map<string, MergedToolPolicy> | undefined,
|
|
1384
|
+
): void {
|
|
1385
|
+
markWaitingApproval(tc, mergedPolicies);
|
|
1386
|
+
applyGateInput(tc, input);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Collapse every tool call still in a non-terminal state (PENDING / RUNNING)
|
|
1391
|
+
* to the hidden SKIPPED row shape, returning how many were collapsed.
|
|
1392
|
+
*
|
|
1393
|
+
* Called only on the pause-for-approval path, after the anchor gate has been
|
|
1394
|
+
* overlaid to WAITING_APPROVAL: the run was cancelled, so nothing will ever
|
|
1395
|
+
* complete these calls, and a permanently-RUNNING row would render as an
|
|
1396
|
+
* eternal spinner beside the approval card. This is the causality sibling of
|
|
1397
|
+
* {@link collapseNonAnchorDenials}: that collapse is token-scoped (it needs the
|
|
1398
|
+
* denial in the ledger), while this one catches the attempt whose hook denial
|
|
1399
|
+
* raced the final ledger read or whose execution the cancel interrupted
|
|
1400
|
+
* outright — either way an attempt with no output that the turn's end orphaned.
|
|
1401
|
+
*/
|
|
1402
|
+
function finalizeInterruptedToolCalls(messages: AgentMessage[]): number {
|
|
1403
|
+
let finalized = 0;
|
|
1404
|
+
for (const msg of messages) {
|
|
1405
|
+
for (const tc of msg.toolCalls) {
|
|
1406
|
+
if (
|
|
1407
|
+
tc.status !== ToolCallStatus.TOOL_CALL_PENDING &&
|
|
1408
|
+
tc.status !== ToolCallStatus.TOOL_CALL_RUNNING
|
|
1409
|
+
) {
|
|
1410
|
+
continue;
|
|
1411
|
+
}
|
|
1412
|
+
hideToolCallRow(tc);
|
|
1413
|
+
finalized++;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return finalized;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Recognizes a tool call already blanked to a hidden collapsed row, so a second
|
|
1421
|
+
* pass never re-collapses it (and never miscounts). Mirrors the SDK's
|
|
1422
|
+
* `isCollapsedToolCall` shape without importing across the runner/SDK seam.
|
|
1423
|
+
*/
|
|
1424
|
+
function isAlreadyCollapsed(tc: ToolCall): boolean {
|
|
1425
|
+
return (
|
|
1426
|
+
tc.status === ToolCallStatus.TOOL_CALL_SKIPPED &&
|
|
1427
|
+
!tc.requiresApproval &&
|
|
1428
|
+
!tc.result &&
|
|
1429
|
+
!tc.error &&
|
|
1430
|
+
!tc.argsPreview
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Whether a tool call carries a change/output of its own — the signal that it is
|
|
1436
|
+
* authoritative for its resource rather than a redundant denial/cancel twin.
|
|
1437
|
+
*
|
|
1438
|
+
* The notion of "change" is category-aware on purpose:
|
|
1439
|
+
* - A file mutation (`write`/`delete`) never carries an authoritative change on
|
|
1440
|
+
* the tool-call ROW: under apply-then-review its review lives in the
|
|
1441
|
+
* `FileChangeSet` ledger (capture mode), and under the no-storage deny-gate it
|
|
1442
|
+
* is the WAITING_APPROVAL gate itself (kept explicitly by the caller). So a
|
|
1443
|
+
* file row is authoritative only as that gate, never on its own — hence
|
|
1444
|
+
* `false` here. (Before Phase 5 Slice 4 this read `file_changes.length > 0`;
|
|
1445
|
+
* that field is gone, and the row was never the review surface.)
|
|
1446
|
+
* - Every other gated tool (shell, MCP) has no ledger; its "change" is its
|
|
1447
|
+
* execution output, so a genuine run carries a non-empty `result` while a
|
|
1448
|
+
* denied/cancelled attempt that never executed does not. This keeps two
|
|
1449
|
+
* distinct shell runs (each with output) both visible while still collapsing a
|
|
1450
|
+
* same-command denial twin.
|
|
1451
|
+
*/
|
|
1452
|
+
function carriesOwnChange(tc: ToolCall): boolean {
|
|
1453
|
+
const category = toolApprovalCategory(tc.name);
|
|
1454
|
+
if (category === "write" || category === "delete") {
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
return !!tc.result;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Collapse redundant same-identity tool-call twins to a single visible row.
|
|
1462
|
+
*
|
|
1463
|
+
* The model frequently emits the SAME gated action twice in one turn (two
|
|
1464
|
+
* tool-call ids, one identity). When the first attempt is gated and the run is
|
|
1465
|
+
* cancelled mid-flight, the extra attempt never receives a terminal event and
|
|
1466
|
+
* persists as a stuck `RUNNING` row ("No preview available"); other variants are
|
|
1467
|
+
* a denied-reported-as-success `COMPLETED` with an empty result, two no-change
|
|
1468
|
+
* `COMPLETED` attempts where neither carries a change, or — on the denial path —
|
|
1469
|
+
* a `FAILED` twin beside the overlaid gate. All render as a duplicate card beside
|
|
1470
|
+
* the real action (or the approval gate). This is the recurring duplicate-card
|
|
1471
|
+
* defect, most visible for file edits but shared by every gated tool family.
|
|
1472
|
+
*
|
|
1473
|
+
* The routine is harness-agnostic and a pure function of `messages`:
|
|
1474
|
+
*
|
|
1475
|
+
* 1. Scope to GATED identities — file mutations (`write`/`delete`) and shell key
|
|
1476
|
+
* on their cross-taxonomy category; MCP tools are recognized by their server
|
|
1477
|
+
* slug. A same-turn duplicate of a gated tool is a denial/cancel artifact, not
|
|
1478
|
+
* meaningful repetition. The category is name-derived (via {@link toolIdentity}
|
|
1479
|
+
* -> approvalCategory), so a twin cancelled before classification (empty
|
|
1480
|
+
* `toolKind`) is still scoped via its name (`edit` -> `write`). Ungated
|
|
1481
|
+
* read-only tools are left untouched.
|
|
1482
|
+
* 2. Group those calls by `toolCallIdentityToken` — the SAME `toolIdentity` used
|
|
1483
|
+
* for denial correlation and resume grants, so scope and grouping cannot drift.
|
|
1484
|
+
* 3. In each group the keepers carry authoritative state — a change/output of
|
|
1485
|
+
* their own (see {@link carriesOwnChange}) or the approval gate itself
|
|
1486
|
+
* (`WAITING_APPROVAL`). For a file mutation the gate is the sole authoritative
|
|
1487
|
+
* row: the row carries no diff (review lives in the `FileChangeSet` ledger, or
|
|
1488
|
+
* is the no-storage deny-gate itself), so a denied write's same-identity
|
|
1489
|
+
* siblings — a denied/zombie row or a stale snapshot from a second attempt —
|
|
1490
|
+
* collapse onto the gate. A shell/MCP twin keeps every distinct run with
|
|
1491
|
+
* output. If NO member qualifies (every attempt produced no change), keep
|
|
1492
|
+
* exactly ONE representative — preferring a terminal attempt over a stuck
|
|
1493
|
+
* `RUNNING` zombie — so the resource still shows a single card. Every
|
|
1494
|
+
* non-keeper is blanked in place to a hidden `SKIPPED` row (see
|
|
1495
|
+
* {@link collapseDenialTwin}).
|
|
1496
|
+
*
|
|
1497
|
+
* It is deliberately subtractive — it only ever HIDES a row, never invents a
|
|
1498
|
+
* terminal state. The committed `id` is preserved on every collapse, so the
|
|
1499
|
+
* finalize stays append-only by construction and the backend's
|
|
1500
|
+
* append-only-at-identity guard accepts it. Returns the number collapsed, for
|
|
1501
|
+
* observability.
|
|
1502
|
+
*/
|
|
1503
|
+
export function collapseRedundantToolCallTwins(messages: AgentMessage[]): number {
|
|
1504
|
+
const groups = new Map<string, ToolCall[]>();
|
|
1505
|
+
for (const msg of messages) {
|
|
1506
|
+
for (const tc of msg.toolCalls) {
|
|
1507
|
+
const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
|
|
1508
|
+
const gated = tc.mcpServerSlug
|
|
1509
|
+
? true
|
|
1510
|
+
: id.key === "write" || id.key === "delete" || id.key === "shell";
|
|
1511
|
+
if (!gated) continue;
|
|
1512
|
+
const token = grantToken(id.key, id.salient);
|
|
1513
|
+
const bucket = groups.get(token);
|
|
1514
|
+
if (bucket) bucket.push(tc);
|
|
1515
|
+
else groups.set(token, [tc]);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
let collapsed = 0;
|
|
1520
|
+
for (const group of groups.values()) {
|
|
1521
|
+
if (group.length < 2) continue; // a lone call is never a twin
|
|
1522
|
+
|
|
1523
|
+
// Keepers carry authoritative state: a change/output of their own
|
|
1524
|
+
// (carriesOwnChange) or the approval gate itself. For a file mutation the gate
|
|
1525
|
+
// is the sole authoritative row (the row carries no diff — review lives in the
|
|
1526
|
+
// ledger, or the row IS the no-storage deny-gate), so a denied write's
|
|
1527
|
+
// same-identity siblings collapse onto it; a shell/MCP twin keeps every
|
|
1528
|
+
// distinct run with output.
|
|
1529
|
+
const keepers = new Set<ToolCall>(
|
|
1530
|
+
group.filter(
|
|
1531
|
+
(tc) =>
|
|
1532
|
+
carriesOwnChange(tc) ||
|
|
1533
|
+
tc.status === ToolCallStatus.TOOL_CALL_WAITING_APPROVAL,
|
|
1534
|
+
),
|
|
1535
|
+
);
|
|
1536
|
+
// All attempts produced no change (e.g. denied-reported-as-success): keep one
|
|
1537
|
+
// representative, preferring a settled outcome over a stuck RUNNING zombie.
|
|
1538
|
+
if (keepers.size === 0) {
|
|
1539
|
+
const terminal = [...group].reverse().find((tc) => isTerminalToolStatus(tc.status));
|
|
1540
|
+
keepers.add(terminal ?? group[0]);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
for (const tc of group) {
|
|
1544
|
+
if (keepers.has(tc)) continue;
|
|
1545
|
+
if (isAlreadyCollapsed(tc)) continue;
|
|
1546
|
+
collapseDenialTwin(tc);
|
|
1547
|
+
collapsed++;
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return collapsed;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* One gate per turn: blank every DENIED tool call whose identity differs from
|
|
1555
|
+
* the anchor (the first denial of the turn) to a hidden SKIPPED row.
|
|
1556
|
+
*
|
|
1557
|
+
* This is the cross-identity complement of {@link collapseRedundantToolCallTwins}
|
|
1558
|
+
* (which only joins SAME-identity duplicates). The canonical target is the
|
|
1559
|
+
* deny-only workaround — a denied `edit notes.md` followed by a `shell:
|
|
1560
|
+
* cat > notes.md` whose identity (`shell\n…`) differs from the edit's
|
|
1561
|
+
* (`write\nnotes.md`), so no twin collapse can join them and (since they share
|
|
1562
|
+
* one assistant message) no positional rule can separate them. The honest signal
|
|
1563
|
+
* that the shell is redundant is that it is a DIFFERENT denied identity in the
|
|
1564
|
+
* same turn as the anchor; under the one-gate-per-turn contract every such
|
|
1565
|
+
* identity is either a post-denial reaction or a co-pending sibling the harness
|
|
1566
|
+
* defers to the next turn, so it is hidden, not surfaced.
|
|
1567
|
+
*
|
|
1568
|
+
* Scoped strictly to identities present in `deniedTokens`: a non-denied tool
|
|
1569
|
+
* (an earlier read/glob, or an already-granted call that ran) is never touched.
|
|
1570
|
+
* Subtractive and id-preserving (via {@link collapseDenialTwin}), so the finalize
|
|
1571
|
+
* stays append-only. Returns the number collapsed, for observability.
|
|
1572
|
+
*/
|
|
1573
|
+
function collapseNonAnchorDenials(
|
|
1574
|
+
messages: AgentMessage[],
|
|
1575
|
+
deniedTokens: ReadonlySet<string>,
|
|
1576
|
+
anchorToken: string,
|
|
1577
|
+
): number {
|
|
1578
|
+
let collapsed = 0;
|
|
1579
|
+
for (const msg of messages) {
|
|
1580
|
+
for (const tc of msg.toolCalls) {
|
|
1581
|
+
const token = toolCallIdentityToken(tc);
|
|
1582
|
+
if (token === anchorToken) continue;
|
|
1583
|
+
if (!deniedTokens.has(token)) continue;
|
|
1584
|
+
if (isAlreadyCollapsed(tc)) continue;
|
|
1585
|
+
collapseDenialTwin(tc);
|
|
1586
|
+
collapsed++;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
return collapsed;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* Blank a superseded denial twin in place to a hidden SKIPPED row. Keeps the
|
|
1594
|
+
* committed `id` (append-only), `name`, and `toolKind`; clears every renderable
|
|
1595
|
+
* surface and the approval flags so the SDK's `isCollapsedToolCall` predicate
|
|
1596
|
+
* recognizes it and renders nothing. The structured `args` are left as the honest
|
|
1597
|
+
* stored record of the redundant attempt (never rendered, since the row is
|
|
1598
|
+
* hidden; the gate carries the authoritative proposed change).
|
|
1599
|
+
*/
|
|
1600
|
+
function collapseDenialTwin(tc: ToolCall): void {
|
|
1601
|
+
hideToolCallRow(tc);
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// `hideToolCallRow` (the "hidden row" shape, shared by the denial-twin collapse
|
|
1605
|
+
// and the capture flow) lives in shared/tool-row.ts so both harnesses collapse
|
|
1606
|
+
// rows identically; imported at the top of this module.
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Overlay the hook-captured authoritative tool input onto a gated tool call so
|
|
1610
|
+
* the approval card can show the proposed change before the user approves.
|
|
1611
|
+
*
|
|
1612
|
+
* When `input` is present it becomes the single source for the preview: the full
|
|
1613
|
+
* structured `args` (the approval card renders the proposed write/edit content
|
|
1614
|
+
* from these), a compact-but-always-valid `args_preview` (the field a resumed
|
|
1615
|
+
* turn parses to rebuild the grant salient — so salient fields are never elided),
|
|
1616
|
+
* and the content digest.
|
|
1617
|
+
*
|
|
1618
|
+
* The digest is the resume identity: it binds the grant to (category, path,
|
|
1619
|
+
* content) so a sibling edit to the same file re-gates rather than riding an
|
|
1620
|
+
* earlier approval through. It is also the identity the Cursor deny-gate's
|
|
1621
|
+
* exact-apply reads on resume — together with the whole-file bytes in `args` — to
|
|
1622
|
+
* write exactly what was approved (see exact-apply.ts). There is no separate
|
|
1623
|
+
* captured `file_changes` mirror; `args` is the single source for both the
|
|
1624
|
+
* preview and the applied bytes.
|
|
1625
|
+
*
|
|
1626
|
+
* With no `input` (the hook's grep fallback) there is nothing authoritative to
|
|
1627
|
+
* stamp and the call keeps its existing args.
|
|
1628
|
+
*/
|
|
1629
|
+
function applyGateInput(
|
|
1630
|
+
tc: ToolCall,
|
|
1631
|
+
input: Record<string, unknown> | undefined,
|
|
1632
|
+
): void {
|
|
1633
|
+
if (!input) return;
|
|
1634
|
+
tc.args = input as JsonObject;
|
|
1635
|
+
tc.argsPreview = buildElidedArgsPreview(input, SALIENT_ARG_FIELDS);
|
|
1636
|
+
// Stamp the content digest from the AUTHORITATIVE captured input, so the
|
|
1637
|
+
// approved edit's exact content survives to resume on a small, never-elided
|
|
1638
|
+
// field — the grant then binds to (category, path, content) and a sibling
|
|
1639
|
+
// edit to the same file re-gates. Empty for a non-content tool. This is the
|
|
1640
|
+
// one place the digest is authored; everything downstream reads the field.
|
|
1641
|
+
tc.approvalContentDigest = contentDigest(input);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
/**
|
|
1645
|
+
* The workspace-normalized identity of a FILE approval category's salient, or
|
|
1646
|
+
* undefined for a non-file category (shell, whose salient is a command, not a
|
|
1647
|
+
* path) or an empty salient. Both the hook-decoded denial salient and a streamed
|
|
1648
|
+
* call's salient pass through this, so an absolute-vs-relative path difference
|
|
1649
|
+
* collapses to one comparable key (`category + "\n" + relPath`). Restricting to
|
|
1650
|
+
* write/delete keeps a shell command from being mangled by path normalization.
|
|
1651
|
+
*/
|
|
1652
|
+
function normalizedFileSalient(
|
|
1653
|
+
category: string,
|
|
1654
|
+
salient: string,
|
|
1655
|
+
workspaceRoot: string,
|
|
1656
|
+
): string | undefined {
|
|
1657
|
+
if ((category !== "write" && category !== "delete") || !salient) return undefined;
|
|
1658
|
+
const { path } = resolveWorkspacePath(salient, workspaceRoot, /* virtualRoot */ false);
|
|
1659
|
+
return `${category}\n${path}`;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Find the first not-yet-overlaid streamed tool call whose workspace-normalized
|
|
1664
|
+
* (category, path) equals `wanted`. Skips calls already claimed by an earlier
|
|
1665
|
+
* denial so several concurrent file denials each overlay a distinct stream call.
|
|
1666
|
+
*/
|
|
1667
|
+
function findUnmatchedStreamCallByNormalizedSalient(
|
|
1668
|
+
messages: AgentMessage[],
|
|
1669
|
+
matchedCalls: ReadonlySet<ToolCall>,
|
|
1670
|
+
wanted: string,
|
|
1671
|
+
workspaceRoot: string,
|
|
1672
|
+
): ToolCall | undefined {
|
|
1673
|
+
for (const msg of messages) {
|
|
1674
|
+
for (const tc of msg.toolCalls) {
|
|
1675
|
+
if (matchedCalls.has(tc)) continue;
|
|
1676
|
+
const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
|
|
1677
|
+
if (normalizedFileSalient(id.key, id.salient, workspaceRoot) === wanted) {
|
|
1678
|
+
return tc;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
return undefined;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* Redact provisional post-denial narration when a Cursor turn pauses for approval.
|
|
1687
|
+
*
|
|
1688
|
+
* THE PROBLEM. Unlike the native harness — which gates with a LangGraph
|
|
1689
|
+
* `interrupt()` *before* the tool runs, so the model never sees a denial — the
|
|
1690
|
+
* Cursor harness can only gate via the file-based `beforeMCPExecution`/
|
|
1691
|
+
* `preToolUse` hook returning `deny`. Cursor surfaces that deny to the model as
|
|
1692
|
+
* a tool *failure* (often its own generic "blocked by a hook" text; see the
|
|
1693
|
+
* Phase 0 ground-truth capture in cursor_hitl_test.go), and there is no
|
|
1694
|
+
* non-leaky SDK approval primitive to use instead (the `request` event is
|
|
1695
|
+
* opaque and carries no responder). So a well-behaved model frequently reacts by
|
|
1696
|
+
* narrating defeat — "I couldn't do this; enable the hook in your Cursor
|
|
1697
|
+
* settings" — which would otherwise be persisted as the assistant's verdict and
|
|
1698
|
+
* rendered right next to the approval card that is, in fact, asking the user to
|
|
1699
|
+
* approve. Contradictory and alarming.
|
|
1700
|
+
*
|
|
1701
|
+
* THE GUARANTEE. The runner's job is to simplify this data, not mirror its
|
|
1702
|
+
* complexity: a turn that pauses for approval must read the SAME shape the
|
|
1703
|
+
* native harness produces — `[pre-tool text][tool calls WAITING_APPROVAL]`, with
|
|
1704
|
+
* no post-denial verdict. We therefore BLANK (clear the `content` of, and mark
|
|
1705
|
+
* non-streaming) the trailing assistant/thinking messages that (a) appear
|
|
1706
|
+
* positionally AFTER the last message bearing a WAITING_APPROVAL tool call and
|
|
1707
|
+
* (b) carry no tool calls of their own. The approval card (projected from the
|
|
1708
|
+
* WAITING_APPROVAL tool-call status) becomes the single, unambiguous source of
|
|
1709
|
+
* truth. The blanked messages are already invisible on every surface via the
|
|
1710
|
+
* existing empty-message handling (`buildThreadItems` skips empty `MESSAGE_AI`;
|
|
1711
|
+
* `MessageEntry` renders nothing for empty `MESSAGE_THINKING`), so the shared
|
|
1712
|
+
* `@stigmer/react`/Ink components stay harness-agnostic with zero per-harness UI
|
|
1713
|
+
* special-casing — the cleanliness lives in the data, not in each consumer.
|
|
1714
|
+
*
|
|
1715
|
+
* WHY BLANK INSTEAD OF REMOVE. Removing the messages would make the persisted
|
|
1716
|
+
* WAITING_FOR_APPROVAL transcript SHORTER than the in-progress transcript the
|
|
1717
|
+
* runner already streamed. The backend's append-only message guard rejects a
|
|
1718
|
+
* shrink for a non-terminal execution (it protects against regressed/partial
|
|
1719
|
+
* writes). Blanking keeps the message COUNT identical, so the finalize is
|
|
1720
|
+
* append-only BY CONSTRUCTION and the guard accepts it with no special case —
|
|
1721
|
+
* which is why this phase deletes the backend's former `isApprovalFinalize`
|
|
1722
|
+
* shrink exception in both editions. The transcript is the authoritative *raw*
|
|
1723
|
+
* record; the verbatim narration text remains recoverable from the runner logs
|
|
1724
|
+
* and the recorded cursor-event stream.
|
|
1725
|
+
*
|
|
1726
|
+
* WHY THIS IS DETERMINISTIC. `attachToolCallToLastAi` calls
|
|
1727
|
+
* `finalizeStreaming(run_id)` before attaching a tool call, so any assistant
|
|
1728
|
+
* text the model emits *after* the denied tool call always starts a NEW message
|
|
1729
|
+
* — post-denial narration is never merged into the message that holds the gated
|
|
1730
|
+
* call. We stop at the first non-narration message — one bearing a VISIBLE tool
|
|
1731
|
+
* call — so legitimately-executed tools after the gate and any text around them
|
|
1732
|
+
* are never touched; only the contiguous trailing reaction block is blanked. A
|
|
1733
|
+
* message whose every tool call was collapsed to the hidden SKIPPED row (a
|
|
1734
|
+
* post-denial workaround or an interrupted attempt — see
|
|
1735
|
+
* collapseNonAnchorDenials / finalizeInterruptedToolCalls, which run first) IS
|
|
1736
|
+
* trailing narration: its rows render as absent, so only its text remains, and
|
|
1737
|
+
* that text is precisely the reaction this redaction exists to blank. Treating
|
|
1738
|
+
* it as a stop would strand every reaction message behind it (the production
|
|
1739
|
+
* shape in aex_01kwj07f7g23c3wp9sn8496z5g: [gate][thinking][narration+workaround
|
|
1740
|
+
* row] — the old walk stopped at the workaround message and redacted nothing).
|
|
1741
|
+
* The first-denial stop in index.ts is the primary mechanism that keeps this
|
|
1742
|
+
* block small (it ends the turn before the model produces inter-tool
|
|
1743
|
+
* narration); this redaction is the backstop for any token that streamed before
|
|
1744
|
+
* the cancel landed.
|
|
1745
|
+
*
|
|
1746
|
+
* Returns the blanked messages (for diagnostics); mutates `messages` in place.
|
|
1747
|
+
*/
|
|
1748
|
+
export function clearProvisionalPostDenialNarration(
|
|
1749
|
+
messages: AgentMessage[],
|
|
1750
|
+
deniedToolCalls: ToolCall[],
|
|
1751
|
+
): AgentMessage[] {
|
|
1752
|
+
if (deniedToolCalls.length === 0) return [];
|
|
1753
|
+
|
|
1754
|
+
// reconcileDeniedToolCalls returns the very ToolCall protos held inside
|
|
1755
|
+
// messages[].toolCalls (overlaid) or appended to the last AI message
|
|
1756
|
+
// (synthesized), so object identity is a stable, exact match.
|
|
1757
|
+
const denied = new Set(deniedToolCalls);
|
|
1758
|
+
|
|
1759
|
+
let lastGatedIdx = -1;
|
|
1760
|
+
for (let i = 0; i < messages.length; i++) {
|
|
1761
|
+
if (messages[i].toolCalls.some((tc) => denied.has(tc))) {
|
|
1762
|
+
lastGatedIdx = i;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
if (lastGatedIdx < 0) return [];
|
|
1766
|
+
|
|
1767
|
+
const redacted: AgentMessage[] = [];
|
|
1768
|
+
for (let i = messages.length - 1; i > lastGatedIdx; i--) {
|
|
1769
|
+
const msg = messages[i];
|
|
1770
|
+
const isProvisionalNarration =
|
|
1771
|
+
(msg.type === MessageType.MESSAGE_AI || msg.type === MessageType.MESSAGE_THINKING) &&
|
|
1772
|
+
msg.toolCalls.every((tc) => isToolCallRowHidden(tc));
|
|
1773
|
+
// Stop at the first message that is NOT trailing narration: a message
|
|
1774
|
+
// bearing a visible (non-collapsed) tool call marks real activity we must
|
|
1775
|
+
// preserve, and anything before it is no longer "trailing".
|
|
1776
|
+
if (!isProvisionalNarration) break;
|
|
1777
|
+
// Blank in place — keep the message so the transcript count never shrinks,
|
|
1778
|
+
// but drop its provisional content so no consumer renders the defeatist
|
|
1779
|
+
// verdict. Empty AI/THINKING messages are hidden by the SDK already; hidden
|
|
1780
|
+
// SKIPPED rows already render as absent.
|
|
1781
|
+
msg.content = "";
|
|
1782
|
+
msg.isStreaming = false;
|
|
1783
|
+
redacted.unshift(msg);
|
|
1784
|
+
}
|
|
1785
|
+
return redacted;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
956
1788
|
/**
|
|
957
1789
|
* Compute a streamed tool call's identity token in the same canonical space the
|
|
958
|
-
* preToolUse hook records denials in (see {@link toolIdentity}
|
|
959
|
-
* The token keys on the cross-taxonomy category + salient resource,
|
|
960
|
-
*
|
|
961
|
-
*
|
|
1790
|
+
* preToolUse hook records denials in (see {@link toolIdentity} / primaryToken).
|
|
1791
|
+
* The token keys on the cross-taxonomy category + salient resource PLUS, for a
|
|
1792
|
+
* file edit/write, the {@link contentDigest} of the edit content — so a stream
|
|
1793
|
+
* `edit` correlates to the hook's `Write` deny for the same path AND content,
|
|
1794
|
+
* and an approval of one edit does not match a DIFFERENT edit to the same file.
|
|
1795
|
+
*
|
|
1796
|
+
* The digest is read from the persisted `approval_content_digest` field when
|
|
1797
|
+
* present (a seeded gate carries it, stable even if `args` was elided), and is
|
|
1798
|
+
* recomputed from the call's args otherwise (a freshly-streamed call). For a
|
|
1799
|
+
* shell/delete/MCP call (no content) it falls back to the coarse token, exactly
|
|
1800
|
+
* as before — so those identities are unchanged.
|
|
1801
|
+
*
|
|
1802
|
+
* Exported so the resume-grant round-trip can be locked against it: the grant a
|
|
1803
|
+
* resume mints for an approved tool (buildApprovalGrants -> primaryToken) must
|
|
1804
|
+
* equal THIS denial/overlay identity, or the re-issued call is re-gated forever
|
|
1805
|
+
* (the dual-path drift the approval-state round-trip suite guards against).
|
|
962
1806
|
*/
|
|
963
|
-
function toolCallIdentityToken(tc: ToolCall): string {
|
|
1807
|
+
export function toolCallIdentityToken(tc: ToolCall): string {
|
|
964
1808
|
const id = toolIdentity(tc.name, tc.mcpServerSlug, toolCallArgs(tc));
|
|
965
|
-
|
|
1809
|
+
const digest = tc.approvalContentDigest || contentDigest(toolCallArgs(tc));
|
|
1810
|
+
return primaryToken(id.key, id.salient, digest);
|
|
966
1811
|
}
|
|
967
1812
|
|
|
968
|
-
/**
|
|
969
|
-
|
|
1813
|
+
/**
|
|
1814
|
+
* Decode a primary token back into its (key, salient, digest) for the synthesis
|
|
1815
|
+
* fallback. The token is `base64(key \n salient)` (coarse) or
|
|
1816
|
+
* `base64(key \n salient \n digest)` (content-exact); the digest is the optional
|
|
1817
|
+
* third segment. salient never contains a newline (a path or shell command), so
|
|
1818
|
+
* splitting on the first two newlines is unambiguous.
|
|
1819
|
+
*/
|
|
1820
|
+
function decodeIdentityToken(
|
|
1821
|
+
token: string,
|
|
1822
|
+
): { key: string; salient: string; digest: string } | undefined {
|
|
970
1823
|
try {
|
|
971
1824
|
const decoded = Buffer.from(token, "base64").toString("utf-8");
|
|
972
|
-
const
|
|
973
|
-
if (
|
|
974
|
-
|
|
1825
|
+
const first = decoded.indexOf("\n");
|
|
1826
|
+
if (first < 0) return undefined;
|
|
1827
|
+
const key = decoded.slice(0, first);
|
|
1828
|
+
const rest = decoded.slice(first + 1);
|
|
1829
|
+
const second = rest.indexOf("\n");
|
|
1830
|
+
if (second < 0) return { key, salient: rest, digest: "" };
|
|
1831
|
+
return { key, salient: rest.slice(0, second), digest: rest.slice(second + 1) };
|
|
975
1832
|
} catch {
|
|
976
1833
|
return undefined;
|
|
977
1834
|
}
|
|
@@ -1017,6 +1874,7 @@ function markWaitingApproval(
|
|
|
1017
1874
|
function synthesizeWaitingApprovalToolCall(
|
|
1018
1875
|
displayName: string,
|
|
1019
1876
|
salient: string,
|
|
1877
|
+
digest: string,
|
|
1020
1878
|
token: string,
|
|
1021
1879
|
mergedPolicies?: Map<string, MergedToolPolicy>,
|
|
1022
1880
|
): ToolCall {
|
|
@@ -1028,6 +1886,10 @@ function synthesizeWaitingApprovalToolCall(
|
|
|
1028
1886
|
startedAt: utcTimestamp(),
|
|
1029
1887
|
approvalRequestedAt: utcTimestamp(),
|
|
1030
1888
|
toolKind: classifyTool(displayName),
|
|
1889
|
+
// Carry the decoded digest so this placeholder's identity (and the grant
|
|
1890
|
+
// rebuilt from it on resume) equals the anchor's content token. applyGateInput
|
|
1891
|
+
// overwrites it from the authoritative input when one was captured.
|
|
1892
|
+
approvalContentDigest: digest,
|
|
1031
1893
|
});
|
|
1032
1894
|
// Carry the salient resource so reconstructAdjudicatedApprovals -> the grant
|
|
1033
1895
|
// builder keys on the same resource the hook will see on the re-attempt.
|
|
@@ -1037,6 +1899,18 @@ function synthesizeWaitingApprovalToolCall(
|
|
|
1037
1899
|
tc.approvalMessage = salient
|
|
1038
1900
|
? `Tool requires approval: ${displayName} (${salient})`
|
|
1039
1901
|
: resolveDeniedApprovalMessage(displayName, "", {}, mergedPolicies);
|
|
1902
|
+
// A synthesized call is a ledger denial — it was gated, so it has a governing
|
|
1903
|
+
// layer. A denied call never occurs under a global bypass or a matching lease,
|
|
1904
|
+
// so empty leases + no bypass faithfully attribute it (a built-in resolves to
|
|
1905
|
+
// builtin_category; an MCP placeholder lacks a reconstructed slug and stays
|
|
1906
|
+
// UNSPECIFIED rather than be mislabeled).
|
|
1907
|
+
if (mergedPolicies) {
|
|
1908
|
+
const source = resolveApprovalProvenance(
|
|
1909
|
+
displayName, "", mergedPolicies, NO_LEASED_CATEGORIES, false,
|
|
1910
|
+
);
|
|
1911
|
+
tc.approvalPolicySource = toProtoPolicySource(source);
|
|
1912
|
+
if (source) tc.policyEngineVersion = POLICY_ENGINE_VERSION;
|
|
1913
|
+
}
|
|
1040
1914
|
return tc;
|
|
1041
1915
|
}
|
|
1042
1916
|
|