@superblocksteam/vite-plugin-file-sync 2.0.167-next.0 → 2.0.169-next.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/ai-service/agent/middleware.d.ts.map +1 -1
- package/dist/ai-service/agent/middleware.js +23 -0
- package/dist/ai-service/agent/middleware.js.map +1 -1
- package/dist/ai-service/agent/prompts/build-base-system-prompt.js +4 -4
- package/dist/ai-service/agent/tool-message-utils.d.ts +1 -1
- package/dist/ai-service/agent/tool-message-utils.d.ts.map +1 -1
- package/dist/ai-service/agent/tool-message-utils.js.map +1 -1
- package/dist/ai-service/agent/tool-permission-delivery.d.ts +5 -4
- package/dist/ai-service/agent/tool-permission-delivery.d.ts.map +1 -1
- package/dist/ai-service/agent/tool-permission-delivery.js +22 -28
- package/dist/ai-service/agent/tool-permission-delivery.js.map +1 -1
- package/dist/ai-service/agent/tool-permission-outcome-metrics.d.ts +9 -0
- package/dist/ai-service/agent/tool-permission-outcome-metrics.d.ts.map +1 -0
- package/dist/ai-service/agent/tool-permission-outcome-metrics.js +23 -0
- package/dist/ai-service/agent/tool-permission-outcome-metrics.js.map +1 -0
- package/dist/ai-service/agent/tools/build-capture-screenshot.d.ts +44 -0
- package/dist/ai-service/agent/tools/build-capture-screenshot.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/build-capture-screenshot.js +198 -18
- package/dist/ai-service/agent/tools/build-capture-screenshot.js.map +1 -1
- package/dist/ai-service/agent/tools/build-manage-checklist.d.ts +2 -0
- package/dist/ai-service/agent/tools/build-manage-checklist.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/build-manage-checklist.js.map +1 -1
- package/dist/ai-service/agent/tools/build-reload-file.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/build-reload-file.js +32 -19
- package/dist/ai-service/agent/tools/build-reload-file.js.map +1 -1
- package/dist/ai-service/agent/tools/get-security-findings.d.ts +45 -0
- package/dist/ai-service/agent/tools/get-security-findings.d.ts.map +1 -0
- package/dist/ai-service/agent/tools/get-security-findings.js +74 -0
- package/dist/ai-service/agent/tools/get-security-findings.js.map +1 -0
- package/dist/ai-service/agent/tools/integrations/integration-setup.d.ts +2 -2
- package/dist/ai-service/agent/tools/integrations/integration-setup.d.ts.map +1 -1
- package/dist/ai-service/agent/tools/integrations/integration-setup.js +24 -2
- package/dist/ai-service/agent/tools/integrations/integration-setup.js.map +1 -1
- package/dist/ai-service/agent/tools/report-security-findings.d.ts +20 -20
- package/dist/ai-service/agent/tools.d.ts.map +1 -1
- package/dist/ai-service/agent/tools.js +13 -2
- package/dist/ai-service/agent/tools.js.map +1 -1
- package/dist/ai-service/agent/tools2/tools/exit-plan-mode.d.ts.map +1 -1
- package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js +14 -0
- package/dist/ai-service/agent/tools2/tools/exit-plan-mode.js.map +1 -1
- package/dist/ai-service/agent/tools2/tools/git.d.ts +2 -1
- package/dist/ai-service/agent/tools2/tools/git.d.ts.map +1 -1
- package/dist/ai-service/agent/tools2/tools/git.js +1 -1
- package/dist/ai-service/agent/tools2/tools/git.js.map +1 -1
- package/dist/ai-service/agent/utils.d.ts.map +1 -1
- package/dist/ai-service/agent/utils.js +5 -0
- package/dist/ai-service/agent/utils.js.map +1 -1
- package/dist/ai-service/app-interface/app-package-manager.d.ts +43 -0
- package/dist/ai-service/app-interface/app-package-manager.d.ts.map +1 -0
- package/dist/ai-service/app-interface/app-package-manager.js +82 -0
- package/dist/ai-service/app-interface/app-package-manager.js.map +1 -0
- package/dist/ai-service/app-interface/constants.d.ts +2 -2
- package/dist/ai-service/app-interface/constants.d.ts.map +1 -1
- package/dist/ai-service/app-interface/constants.js +5 -0
- package/dist/ai-service/app-interface/constants.js.map +1 -1
- package/dist/ai-service/app-interface/file-system-interface.d.ts.map +1 -1
- package/dist/ai-service/app-interface/file-system-interface.js +0 -3
- package/dist/ai-service/app-interface/file-system-interface.js.map +1 -1
- package/dist/ai-service/app-interface/npm-error-parser.d.ts +1 -1
- package/dist/ai-service/app-interface/npm-error-parser.d.ts.map +1 -1
- package/dist/ai-service/app-interface/npm-error-parser.js +42 -8
- package/dist/ai-service/app-interface/npm-error-parser.js.map +1 -1
- package/dist/ai-service/app-interface/npm-registry.d.ts +30 -0
- package/dist/ai-service/app-interface/npm-registry.d.ts.map +1 -1
- package/dist/ai-service/app-interface/npm-registry.js +108 -1
- package/dist/ai-service/app-interface/npm-registry.js.map +1 -1
- package/dist/ai-service/app-interface/pnpm-lockfile.d.ts +28 -0
- package/dist/ai-service/app-interface/pnpm-lockfile.d.ts.map +1 -0
- package/dist/ai-service/app-interface/pnpm-lockfile.js +89 -0
- package/dist/ai-service/app-interface/pnpm-lockfile.js.map +1 -0
- package/dist/ai-service/app-interface/shell.d.ts +43 -7
- package/dist/ai-service/app-interface/shell.d.ts.map +1 -1
- package/dist/ai-service/app-interface/shell.js +252 -119
- package/dist/ai-service/app-interface/shell.js.map +1 -1
- package/dist/ai-service/app-interface/vulnerability-gate.d.ts +5 -0
- package/dist/ai-service/app-interface/vulnerability-gate.d.ts.map +1 -1
- package/dist/ai-service/app-interface/vulnerability-gate.js +3 -0
- package/dist/ai-service/app-interface/vulnerability-gate.js.map +1 -1
- package/dist/ai-service/app-skills/helpers.d.ts.map +1 -1
- package/dist/ai-service/app-skills/helpers.js +9 -9
- package/dist/ai-service/app-skills/helpers.js.map +1 -1
- package/dist/ai-service/chat/chat-session-store.d.ts +4 -1
- package/dist/ai-service/chat/chat-session-store.d.ts.map +1 -1
- package/dist/ai-service/chat/chat-session-store.js +4 -0
- package/dist/ai-service/chat/chat-session-store.js.map +1 -1
- package/dist/ai-service/checklist/persisted-checklist-store.d.ts +66 -1
- package/dist/ai-service/checklist/persisted-checklist-store.d.ts.map +1 -1
- package/dist/ai-service/checklist/persisted-checklist-store.js +274 -4
- package/dist/ai-service/checklist/persisted-checklist-store.js.map +1 -1
- package/dist/ai-service/checklist/policy-remediation.d.ts +8 -0
- package/dist/ai-service/checklist/policy-remediation.d.ts.map +1 -0
- package/dist/ai-service/checklist/policy-remediation.js +46 -0
- package/dist/ai-service/checklist/policy-remediation.js.map +1 -0
- package/dist/ai-service/checklist/remediation-findings-client.d.ts +31 -0
- package/dist/ai-service/checklist/remediation-findings-client.d.ts.map +1 -0
- package/dist/ai-service/checklist/remediation-findings-client.js +117 -0
- package/dist/ai-service/checklist/remediation-findings-client.js.map +1 -0
- package/dist/ai-service/context-download.d.ts.map +1 -1
- package/dist/ai-service/context-download.js +76 -37
- package/dist/ai-service/context-download.js.map +1 -1
- package/dist/ai-service/dev-database-file-sync.d.ts +2 -1
- package/dist/ai-service/dev-database-file-sync.d.ts.map +1 -1
- package/dist/ai-service/dev-database-file-sync.js.map +1 -1
- package/dist/ai-service/index.d.ts +59 -9
- package/dist/ai-service/index.d.ts.map +1 -1
- package/dist/ai-service/index.js +1191 -479
- package/dist/ai-service/index.js.map +1 -1
- package/dist/ai-service/integrations/store.d.ts.map +1 -1
- package/dist/ai-service/integrations/store.js +11 -2
- package/dist/ai-service/integrations/store.js.map +1 -1
- package/dist/ai-service/llm/context-v2/adapter.d.ts +3 -0
- package/dist/ai-service/llm/context-v2/adapter.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/adapter.js +6 -0
- package/dist/ai-service/llm/context-v2/adapter.js.map +1 -1
- package/dist/ai-service/llm/context-v2/context.d.ts +22 -1
- package/dist/ai-service/llm/context-v2/context.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/context.js +185 -30
- package/dist/ai-service/llm/context-v2/context.js.map +1 -1
- package/dist/ai-service/llm/context-v2/conversation-context.d.ts +7 -0
- package/dist/ai-service/llm/context-v2/conversation-context.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/manager.d.ts +11 -10
- package/dist/ai-service/llm/context-v2/manager.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/manager.js +128 -85
- package/dist/ai-service/llm/context-v2/manager.js.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/event-store.d.ts +73 -1
- package/dist/ai-service/llm/context-v2/storage/event-store.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/event-types.d.ts +152 -24
- package/dist/ai-service/llm/context-v2/storage/event-types.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/event-types.js +141 -0
- package/dist/ai-service/llm/context-v2/storage/event-types.js.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts +20 -2
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js +111 -49
- package/dist/ai-service/llm/context-v2/storage/jsonl-event-store.js.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/migration.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/storage/migration.js +31 -4
- package/dist/ai-service/llm/context-v2/storage/migration.js.map +1 -1
- package/dist/ai-service/llm/context-v2/token-tracker.d.ts +34 -0
- package/dist/ai-service/llm/context-v2/token-tracker.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/token-tracker.js +164 -0
- package/dist/ai-service/llm/context-v2/token-tracker.js.map +1 -1
- package/dist/ai-service/llm/context-v2/types.d.ts +5 -2
- package/dist/ai-service/llm/context-v2/types.d.ts.map +1 -1
- package/dist/ai-service/llm/context-v2/types.js +1 -1
- package/dist/ai-service/llm/context-v2/types.js.map +1 -1
- package/dist/ai-service/llm/stream/config.d.ts +7 -0
- package/dist/ai-service/llm/stream/config.d.ts.map +1 -1
- package/dist/ai-service/llm/stream/config.js +20 -2
- package/dist/ai-service/llm/stream/config.js.map +1 -1
- package/dist/ai-service/llm/stream/orchestrator.d.ts.map +1 -1
- package/dist/ai-service/llm/stream/orchestrator.js +30 -14
- package/dist/ai-service/llm/stream/orchestrator.js.map +1 -1
- package/dist/ai-service/llm/stream/session.d.ts +1 -1
- package/dist/ai-service/llm/stream/session.d.ts.map +1 -1
- package/dist/ai-service/llm/stream/session.js +1 -1
- package/dist/ai-service/llm/stream/session.js.map +1 -1
- package/dist/ai-service/migration-llm-config.d.ts.map +1 -1
- package/dist/ai-service/migration-llm-config.js +13 -71
- package/dist/ai-service/migration-llm-config.js.map +1 -1
- package/dist/ai-service/security/secret-scanner-service.d.ts.map +1 -1
- package/dist/ai-service/security/secret-scanner-service.js +12 -2
- package/dist/ai-service/security/secret-scanner-service.js.map +1 -1
- package/dist/ai-service/security/secret-scanner.d.ts +1 -1
- package/dist/ai-service/security/secret-scanner.d.ts.map +1 -1
- package/dist/ai-service/security/secret-scanner.js +4 -1
- package/dist/ai-service/security/secret-scanner.js.map +1 -1
- package/dist/ai-service/security/vulnerability-triage.d.ts +2 -2
- package/dist/ai-service/state-machine/clark-fsm.d.ts +17 -1
- package/dist/ai-service/state-machine/clark-fsm.d.ts.map +1 -1
- package/dist/ai-service/state-machine/clark-fsm.js +85 -8
- package/dist/ai-service/state-machine/clark-fsm.js.map +1 -1
- package/dist/ai-service/state-machine/handlers/agent-planning.d.ts.map +1 -1
- package/dist/ai-service/state-machine/handlers/agent-planning.js +97 -47
- package/dist/ai-service/state-machine/handlers/agent-planning.js.map +1 -1
- package/dist/ai-service/state-machine/handlers/awaiting-user.d.ts.map +1 -1
- package/dist/ai-service/state-machine/handlers/awaiting-user.js +68 -50
- package/dist/ai-service/state-machine/handlers/awaiting-user.js.map +1 -1
- package/dist/ai-service/state-machine/handlers/llm-generating.d.ts.map +1 -1
- package/dist/ai-service/state-machine/handlers/llm-generating.js +91 -73
- package/dist/ai-service/state-machine/handlers/llm-generating.js.map +1 -1
- package/dist/ai-service/state-machine/helpers/prompt-failure.d.ts +1 -1
- package/dist/ai-service/state-machine/helpers/prompt-failure.d.ts.map +1 -1
- package/dist/ai-service/state-machine/helpers/prompt-failure.js +1 -0
- package/dist/ai-service/state-machine/helpers/prompt-failure.js.map +1 -1
- package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts +2 -1
- package/dist/ai-service/state-machine/helpers/prompt-request-metrics.d.ts.map +1 -1
- package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js +1 -0
- package/dist/ai-service/state-machine/helpers/prompt-request-metrics.js.map +1 -1
- package/dist/ai-service/state-machine/helpers/user-response-message.d.ts +4 -0
- package/dist/ai-service/state-machine/helpers/user-response-message.d.ts.map +1 -0
- package/dist/ai-service/state-machine/helpers/user-response-message.js +37 -0
- package/dist/ai-service/state-machine/helpers/user-response-message.js.map +1 -0
- package/dist/ai-service/template-renderer.d.ts.map +1 -1
- package/dist/ai-service/template-renderer.js +22 -9
- package/dist/ai-service/template-renderer.js.map +1 -1
- package/dist/ai-service/types.d.ts +2 -1
- package/dist/ai-service/types.d.ts.map +1 -1
- package/dist/ai-service/types.js.map +1 -1
- package/dist/components-manager.d.ts +9 -2
- package/dist/components-manager.d.ts.map +1 -1
- package/dist/components-manager.js +0 -4
- package/dist/components-manager.js.map +1 -1
- package/dist/file-sync-vite-plugin.d.ts.map +1 -1
- package/dist/file-sync-vite-plugin.js +4 -8
- package/dist/file-sync-vite-plugin.js.map +1 -1
- package/dist/file-system-helpers.d.ts +14 -0
- package/dist/file-system-helpers.d.ts.map +1 -1
- package/dist/file-system-helpers.js +22 -1
- package/dist/file-system-helpers.js.map +1 -1
- package/dist/file-system-manager.d.ts +12 -1
- package/dist/file-system-manager.d.ts.map +1 -1
- package/dist/file-system-manager.js +42 -22
- package/dist/file-system-manager.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/lock-service/index.d.ts +20 -0
- package/dist/lock-service/index.d.ts.map +1 -1
- package/dist/lock-service/index.js +53 -5
- package/dist/lock-service/index.js.map +1 -1
- package/dist/migration/get-fullstack-template-dir.js +2 -2
- package/dist/migration/get-fullstack-template-dir.js.map +1 -1
- package/dist/migration/migration-routes.d.ts.map +1 -1
- package/dist/migration/migration-routes.js +8 -2
- package/dist/migration/migration-routes.js.map +1 -1
- package/dist/plugin-options.d.ts +1 -0
- package/dist/plugin-options.d.ts.map +1 -1
- package/dist/plugin-options.js.map +1 -1
- package/dist/router-parser.d.ts.map +1 -1
- package/dist/router-parser.js +336 -65
- package/dist/router-parser.js.map +1 -1
- package/dist/socket-manager.d.ts +25 -2
- package/dist/socket-manager.d.ts.map +1 -1
- package/dist/socket-manager.js +270 -55
- package/dist/socket-manager.js.map +1 -1
- package/dist/split-edit-after-clark.d.ts +14 -0
- package/dist/split-edit-after-clark.d.ts.map +1 -0
- package/dist/split-edit-after-clark.js +40 -0
- package/dist/split-edit-after-clark.js.map +1 -0
- package/dist/sync-service/index.d.ts +2 -1
- package/dist/sync-service/index.d.ts.map +1 -1
- package/dist/sync-service/index.js +5 -0
- package/dist/sync-service/index.js.map +1 -1
- package/dist/sync-service/list-dir.d.ts.map +1 -1
- package/dist/sync-service/list-dir.js +9 -4
- package/dist/sync-service/list-dir.js.map +1 -1
- package/dist/util/log-sanitizer.d.ts.map +1 -1
- package/dist/util/log-sanitizer.js +4 -0
- package/dist/util/log-sanitizer.js.map +1 -1
- package/dist/vite-module-reload.d.ts +34 -0
- package/dist/vite-module-reload.d.ts.map +1 -0
- package/dist/vite-module-reload.js +121 -0
- package/dist/vite-module-reload.js.map +1 -0
- package/package.json +10 -9
package/dist/ai-service/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { context as otelContext, propagation, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
4
|
+
import { context as otelContext, metrics, propagation, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
5
5
|
import { hasToolCall, stepCountIs, } from "ai";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { AiGenerationState, AiMode, resolveAttachmentFileName, } from "@superblocksteam/library-shared/types";
|
|
@@ -12,10 +12,12 @@ import { applyTestApiDenialSideEffects, buildMigrationTestApiDenialContinuationP
|
|
|
12
12
|
import { buildTranslationPrompt } from "../migration/translation-prompt.js";
|
|
13
13
|
import { POLICY_GATE_RUN_IN_PROGRESS_ERROR, policyGateRunRegistry, } from "../policy-gate-run-registry.js";
|
|
14
14
|
import { getErrorMeta, getLogger, setDefaultLogger } from "../util/logger.js";
|
|
15
|
+
import { OperationQueue } from "../util/operation-queue.js";
|
|
15
16
|
import { composeSecurityAgentPrompt, } from "./agent/prompts/build-security-scan-prompt.js";
|
|
16
17
|
import { getToolCallArguments, getToolErrorCode, getToolErrorMessage, getToolOutput, } from "./agent/tool-message-utils.js";
|
|
17
18
|
import { recordToolPermissionDeliveryOutcome } from "./agent/tool-permission-delivery-metrics.js";
|
|
18
19
|
import { createExpiredPermissionPromptDeliveryError, flushPendingToolPermissionPrompt, inFlightToolPermissionReplay, } from "./agent/tool-permission-delivery.js";
|
|
20
|
+
import { recordToolPermissionLateOutcome } from "./agent/tool-permission-outcome-metrics.js";
|
|
19
21
|
import { buildSecurityScanTools, isBackgroundTaskFrameworkEnabled, } from "./agent/tools.js";
|
|
20
22
|
import { hasPendingEscalations, flushPendingEscalations, } from "./agent/tools/apis/api-validation-orchestrator.js";
|
|
21
23
|
import { safeSampleJson } from "./agent/tools/apis/sample-json.js";
|
|
@@ -37,7 +39,8 @@ import { isUploadedAttachment, toUploadedAttachmentContentPart, } from "./attach
|
|
|
37
39
|
import { recordChatSessionIdLookup } from "./chat/chat-session-store-metrics.js";
|
|
38
40
|
import { ChatSessionStore } from "./chat/chat-session-store.js";
|
|
39
41
|
import { normalizeTextAttachmentForModel } from "./chat/utils.js";
|
|
40
|
-
import { API_MIGRATION_ORIGINS, isApiMigrationOrigin, readPersistedChecklist, updateChecklistItem, } from "./checklist/persisted-checklist-store.js";
|
|
42
|
+
import { API_MIGRATION_ORIGINS, cancelPolicyRemediation, seedPolicyRemediationChecklist, isApiMigrationOrigin, readPersistedChecklist, readRemediationRun, updateChecklistItem, advancePolicyRemediationTurn, MAX_REMEDIATION_TURNS, failPolicyRemediationDispatch, } from "./checklist/persisted-checklist-store.js";
|
|
43
|
+
import { fetchRemediationFindings } from "./checklist/remediation-findings-client.js";
|
|
41
44
|
import { downloadContextFromBucketeer, downloadLatestContextFromBucketeer, restoreContextFromCheckpoint, } from "./context-download.js";
|
|
42
45
|
import { restoreConversationToCheckpoint, } from "./context-restore.js";
|
|
43
46
|
import { uploadContextToBucketeer } from "./context-upload.js";
|
|
@@ -63,6 +66,8 @@ import { ContextManagerV2 } from "./llm/context-v2/manager.js";
|
|
|
63
66
|
import { JsonlEventStore } from "./llm/context-v2/storage/jsonl-event-store.js";
|
|
64
67
|
import { adoptLegacyPerUserContextDirs } from "./llm/context-v2/storage/legacy-context-adoption.js";
|
|
65
68
|
import { LocalContextStorageV2 } from "./llm/context-v2/storage/local.js";
|
|
69
|
+
import { buildRestoredContextUsageSnapshot } from "./llm/context-v2/token-tracker.js";
|
|
70
|
+
import { toJsonValue, } from "./llm/context-v2/types.js";
|
|
66
71
|
import { createLLMProvider, setSpanError } from "./llm/provider.js";
|
|
67
72
|
import { createAbortReason } from "./llm/stream/abort-reason.js";
|
|
68
73
|
import { getJwtTraceTags, traceLLM } from "./llmobs/helpers.js";
|
|
@@ -86,7 +91,7 @@ import { RequestDeduplicator } from "./request-deduplicator.js";
|
|
|
86
91
|
import { resolveGenerationStateForUi } from "./resolve-generation-state-for-ui.js";
|
|
87
92
|
import { scanContentForSecrets } from "./security/secret-scanner-service.js";
|
|
88
93
|
import { SecretRedactor } from "./security/secret-scanner.js";
|
|
89
|
-
import { ClarkStateNames, Clark, SERVICE_STARTED_WITH_DRAFT, USER_ACCEPTED_DRAFT, USER_REJECTED_DRAFT, USER_SENT_PROMPT, USER_CANCELED, AGENT_CANCELED, } from "./state-machine/clark-fsm.js";
|
|
94
|
+
import { ClarkStateNames, Clark, SERVICE_STARTED_WITH_DRAFT, USER_ACCEPTED_DRAFT, USER_REJECTED_DRAFT, USER_SENT_PROMPT, USER_CANCELED, AGENT_CANCELED, APP_RUNTIME_UPDATED_WITHOUT_EDITS, RUNTIME_REVIEW_FINISHED, V3_AGENT_FINISHED, } from "./state-machine/clark-fsm.js";
|
|
90
95
|
import { doAgentPlanning } from "./state-machine/handlers/agent-planning.js";
|
|
91
96
|
import { doAwaitingUser } from "./state-machine/handlers/awaiting-user.js";
|
|
92
97
|
import { doIdle } from "./state-machine/handlers/idle.js";
|
|
@@ -106,6 +111,8 @@ import { paywallStopAdvice } from "./state-machine/helpers/prompt-failure.js";
|
|
|
106
111
|
import { CLARK_USAGE_ORIGIN, promptFailureFromStreamAbort, promptFailurePaywall, promptFailurePlatform, promptFailureUserCanceled, PROMPT_ATTEMPT_ENTRYPOINT, recordBaseCommitResolutionFailed, recordPromptAttempt, recordPromptEntryRejection, recordPromptOutcome, recordAwaitingUserHandoffDismissed, resolvePromptMetricLabels, resolvePromptOutcomeDedupeKey, resolvePromptRequestDedupeKey, setPromptRequestMetricIdentity, } from "./state-machine/helpers/prompt-request-metrics.js";
|
|
107
112
|
import { catchBackgroundPeerDisconnect, StablePeer, } from "./state-machine/helpers/stable-peer.js";
|
|
108
113
|
import { transitionFrom } from "./state-machine/helpers/transition.js";
|
|
114
|
+
import { resolveUserPreferences } from "./state-machine/helpers/user-preferences.js";
|
|
115
|
+
import { createUserResponseMessage } from "./state-machine/helpers/user-response-message.js";
|
|
109
116
|
import { isTaskAccessibleTo, registerBackgroundTaskTypes, resyncActiveTaskStatusesToPeer, TaskStore, wireTaskStatusPushToPeer, } from "./tasks/index.js";
|
|
110
117
|
import { TemplateRenderer } from "./template-renderer.js";
|
|
111
118
|
import { describeMigrationInferenceJwt, isUsableMigrationInferenceJwt, } from "./util/inference-jwt.js";
|
|
@@ -439,11 +446,46 @@ const POLICY_GATE_USAGE_METADATA = {
|
|
|
439
446
|
const ERROR_JSON_OUTPUT_TYPE = "error-json";
|
|
440
447
|
const TOOL_MESSAGE_ROLE = "tool";
|
|
441
448
|
const TOOL_RESULT_CONTENT_TYPE = "tool-result";
|
|
449
|
+
const TOOL_PERMISSION_CONTEXT_ATTEMPTS = 3;
|
|
450
|
+
const TOOL_PERMISSION_CONTEXT_RETRY_MS = 200;
|
|
451
|
+
const CLARIFICATION_DENY_REASON = "The user answered with the message that follows instead of approving.";
|
|
452
|
+
const TOOL_PERMISSION_PLACEHOLDER_USAGE = {
|
|
453
|
+
inputTokens: 0,
|
|
454
|
+
outputTokens: 0,
|
|
455
|
+
totalTokens: 0,
|
|
456
|
+
inputTokenDetails: {
|
|
457
|
+
noCacheTokens: 0,
|
|
458
|
+
cacheReadTokens: 0,
|
|
459
|
+
cacheWriteTokens: 0,
|
|
460
|
+
},
|
|
461
|
+
outputTokenDetails: {
|
|
462
|
+
textTokens: 0,
|
|
463
|
+
reasoningTokens: 0,
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
/**
|
|
467
|
+
* Provider-facing context expects tool failures to be encoded as errored tool
|
|
468
|
+
* results so they still satisfy the preceding tool call.
|
|
469
|
+
*/
|
|
470
|
+
function toolErrorResult(toolCallId, toolName, error) {
|
|
471
|
+
return {
|
|
472
|
+
role: TOOL_MESSAGE_ROLE,
|
|
473
|
+
content: [
|
|
474
|
+
{
|
|
475
|
+
type: TOOL_RESULT_CONTENT_TYPE,
|
|
476
|
+
toolCallId,
|
|
477
|
+
toolName,
|
|
478
|
+
output: { type: ERROR_JSON_OUTPUT_TYPE, value: { error } },
|
|
479
|
+
},
|
|
480
|
+
],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
442
483
|
export class AiService extends TracedEventEmitter {
|
|
443
484
|
config;
|
|
444
485
|
templateRenderer;
|
|
445
486
|
appShell;
|
|
446
487
|
fileSystemInterface;
|
|
488
|
+
fileSystemManager;
|
|
447
489
|
integrationStore;
|
|
448
490
|
knowledgeManager;
|
|
449
491
|
_appContext;
|
|
@@ -468,6 +510,8 @@ export class AiService extends TracedEventEmitter {
|
|
|
468
510
|
entityPermissionStore = new SessionEntityPermissionStore();
|
|
469
511
|
generationCompleteWaiters = new Set();
|
|
470
512
|
activeGenerationTrace;
|
|
513
|
+
currentGenerationId;
|
|
514
|
+
generationStarting = false;
|
|
471
515
|
/** JWT-derived userId of the currently-authenticated peer, set by
|
|
472
516
|
* `notifyAuthenticatedRpc` and cleared on disconnect / peer swap.
|
|
473
517
|
* Only trusted while `authenticatedPeerId` still matches the bound
|
|
@@ -656,7 +700,11 @@ export class AiService extends TracedEventEmitter {
|
|
|
656
700
|
return false;
|
|
657
701
|
}
|
|
658
702
|
getLogger().info("[ai-service] Accepted a new inference JWT");
|
|
703
|
+
const hadUser = this.getContextId() !== null;
|
|
659
704
|
this.clark.updateContext({ jwt });
|
|
705
|
+
if (!hadUser) {
|
|
706
|
+
void this.pushRestoredContextUsage();
|
|
707
|
+
}
|
|
660
708
|
const llmConfig = this.lastMigrationTurnCredentials?.llmConfig ??
|
|
661
709
|
this.clark.context.llmConfig ??
|
|
662
710
|
buildServerMigrationLlmConfig(this.config.flagBootstrap);
|
|
@@ -1066,7 +1114,9 @@ export class AiService extends TracedEventEmitter {
|
|
|
1066
1114
|
this.config.draftInterface
|
|
1067
1115
|
.hasLocalDraftChanges()
|
|
1068
1116
|
.then((hasDraft) => {
|
|
1069
|
-
if (hasDraft
|
|
1117
|
+
if (hasDraft &&
|
|
1118
|
+
this.currentGenerationId === undefined &&
|
|
1119
|
+
this.clark.state === ClarkStateNames.Idle) {
|
|
1070
1120
|
this.clark.updateContext({
|
|
1071
1121
|
hasSuggestions: true,
|
|
1072
1122
|
});
|
|
@@ -1123,14 +1173,17 @@ export class AiService extends TracedEventEmitter {
|
|
|
1123
1173
|
this.getLogger().info("[ai-service] Initialized");
|
|
1124
1174
|
}
|
|
1125
1175
|
async hydrateChecklistFromDisk() {
|
|
1126
|
-
|
|
1176
|
+
let checklist = await readPersistedChecklist(this.appRootDirPath);
|
|
1177
|
+
this.remediationRequestId = (await readRemediationRun(this.appRootDirPath))?.requestId;
|
|
1127
1178
|
if (!checklist) {
|
|
1128
1179
|
return;
|
|
1129
1180
|
}
|
|
1130
1181
|
// Reset stale in_progress items to pending. After a dev-server restart,
|
|
1131
1182
|
// items left in_progress are not truly running — resetting them allows
|
|
1132
1183
|
// the UI polling trigger and backend handler to re-dispatch Clark.
|
|
1133
|
-
const staleInProgressItems = checklist.items.filter((item) => API_MIGRATION_ORIGINS.has(item.origin)
|
|
1184
|
+
const staleInProgressItems = checklist.items.filter((item) => (API_MIGRATION_ORIGINS.has(item.origin) ||
|
|
1185
|
+
item.source === "policy_remediation") &&
|
|
1186
|
+
item.status === "in_progress");
|
|
1134
1187
|
for (const item of staleInProgressItems) {
|
|
1135
1188
|
await updateChecklistItem({
|
|
1136
1189
|
appRootDirPath: this.appRootDirPath,
|
|
@@ -1139,6 +1192,8 @@ export class AiService extends TracedEventEmitter {
|
|
|
1139
1192
|
});
|
|
1140
1193
|
this.getLogger().info(`[ai-service] Reset stale in_progress item "${item.id}" to pending after restart`);
|
|
1141
1194
|
}
|
|
1195
|
+
checklist =
|
|
1196
|
+
(await readPersistedChecklist(this.appRootDirPath)) ?? checklist;
|
|
1142
1197
|
const hasPendingSeedApi = checklist.items.some((item) => API_MIGRATION_ORIGINS.has(item.origin) &&
|
|
1143
1198
|
(item.status === "pending" || item.status === "in_progress"));
|
|
1144
1199
|
if (hasPendingSeedApi) {
|
|
@@ -1395,6 +1450,7 @@ export class AiService extends TracedEventEmitter {
|
|
|
1395
1450
|
return (fsm) => {
|
|
1396
1451
|
const clark = fsm;
|
|
1397
1452
|
return async (transition) => {
|
|
1453
|
+
const generationId = this.currentGenerationId;
|
|
1398
1454
|
const params = {
|
|
1399
1455
|
...this.config,
|
|
1400
1456
|
logger: this.getLogger(),
|
|
@@ -1427,6 +1483,7 @@ export class AiService extends TracedEventEmitter {
|
|
|
1427
1483
|
toolRegistry: this.toolRegistry,
|
|
1428
1484
|
entityPermissionStore: this.entityPermissionStore,
|
|
1429
1485
|
onToolPermissionParked: (messageId) => this.scheduleToolPermissionReplayWindow(messageId),
|
|
1486
|
+
resolveToolPermissionResponse: (request) => this.resolveToolPermissionResponse(request),
|
|
1430
1487
|
mcpServerManager: this.mcpServerManager,
|
|
1431
1488
|
viteServer: this.viteServer,
|
|
1432
1489
|
recordingManager: this.recordingManager,
|
|
@@ -1449,6 +1506,9 @@ export class AiService extends TracedEventEmitter {
|
|
|
1449
1506
|
};
|
|
1450
1507
|
const clarkStateHandlers = Object.fromEntries(Object.entries(clarkStateHandlerFactories).map(([state, handlerFactory]) => [state, handlerFactory(clark, params)]));
|
|
1451
1508
|
const { from, to, event } = transition;
|
|
1509
|
+
const remediationTurnId = clark.context.promptOutcomeDedupeKey ?? clark.context.promptId;
|
|
1510
|
+
const remediationLlmConfig = clark.context.llmConfig;
|
|
1511
|
+
const remediationRequestId = this.remediationRequestId;
|
|
1452
1512
|
this.clarkProfiler.onStateTransition(from, to, event);
|
|
1453
1513
|
if (clark.context.runTimestamp) {
|
|
1454
1514
|
this.clarkProfiler.initialize(this.appShell, clark.context.runTimestamp);
|
|
@@ -1459,12 +1519,74 @@ export class AiService extends TracedEventEmitter {
|
|
|
1459
1519
|
throw new Error(`No handler for state ${to}`);
|
|
1460
1520
|
}
|
|
1461
1521
|
this.getLogger().info(`[ai-service] Transitioned from ${from} to ${to} with event ${event.type}`);
|
|
1522
|
+
const { promptId, promptOutcomeDedupeKey } = clark.context;
|
|
1523
|
+
let handlerCompleted = false;
|
|
1462
1524
|
try {
|
|
1525
|
+
if (event.type === USER_SENT_PROMPT &&
|
|
1526
|
+
remediationRequestId &&
|
|
1527
|
+
(event.request.policyGateRemediation ||
|
|
1528
|
+
event.request.responseMetadata)) {
|
|
1529
|
+
const turnId = event.request.requestId ?? randomUUID();
|
|
1530
|
+
clark.updateContext({ promptOutcomeDedupeKey: turnId });
|
|
1531
|
+
const result = await this.remediationChecklistQueue.enqueue(async () => {
|
|
1532
|
+
const result = await advancePolicyRemediationTurn({
|
|
1533
|
+
appRootDirPath: this.appRootDirPath,
|
|
1534
|
+
applicationId: this.config.applicationId,
|
|
1535
|
+
turnId,
|
|
1536
|
+
phase: event.request.responseMetadata ? "resume" : "start",
|
|
1537
|
+
requestId: remediationRequestId,
|
|
1538
|
+
});
|
|
1539
|
+
await this.publishRemediationTurnResult(result);
|
|
1540
|
+
return result;
|
|
1541
|
+
});
|
|
1542
|
+
if (result.outcome === "turn_limit") {
|
|
1543
|
+
if (!event.internal)
|
|
1544
|
+
await this.persistRejectedUserInput(event.request, event.inputSource);
|
|
1545
|
+
await clark.send({
|
|
1546
|
+
type: AGENT_CANCELED,
|
|
1547
|
+
advice: `Security remediation stopped after ${MAX_REMEDIATION_TURNS} turns. Retry to continue the remaining groups.`,
|
|
1548
|
+
failure: promptFailurePlatform("retries_exhausted"),
|
|
1549
|
+
});
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
if (clark.state !== to || clark.context.userInitiatedPromptAbort)
|
|
1553
|
+
return;
|
|
1554
|
+
clark.updateContext({
|
|
1555
|
+
usageMetadata: buildPolicyGateRemediationUsageMetadata(result.state?.policyGateRemediation),
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1463
1558
|
await handler(transition);
|
|
1559
|
+
handlerCompleted = true;
|
|
1560
|
+
if (event.type === USER_SENT_PROMPT &&
|
|
1561
|
+
event.internal &&
|
|
1562
|
+
event.request.policyGateRemediation &&
|
|
1563
|
+
remediationRequestId &&
|
|
1564
|
+
this.ownsRemediationRun(remediationRequestId)) {
|
|
1565
|
+
this.recordRemediationDispatch("started", "dispatched");
|
|
1566
|
+
}
|
|
1464
1567
|
}
|
|
1465
1568
|
catch (error) {
|
|
1569
|
+
if (event.type === USER_SENT_PROMPT &&
|
|
1570
|
+
event.executeToolPermissionResponse &&
|
|
1571
|
+
(clark.context.promptId !== promptId ||
|
|
1572
|
+
clark.context.promptOutcomeDedupeKey !== promptOutcomeDedupeKey)) {
|
|
1573
|
+
// A newer turn owns Clark now, so this failure must not cancel it.
|
|
1574
|
+
this.getLogger().warn("[ai-service] A superseded tool permission turn failed after a newer turn took over", {
|
|
1575
|
+
...getErrorMeta(error),
|
|
1576
|
+
currentPromptId: clark.context.promptId,
|
|
1577
|
+
supersededPromptId: promptId,
|
|
1578
|
+
});
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1466
1581
|
const errorMeta = getErrorMeta(error);
|
|
1467
1582
|
this.getLogger().error(`[ai-service] Error in handler for state ${to} with event ${event.type}: ${errorMeta.error.message}`, errorMeta);
|
|
1583
|
+
if (event.type === USER_SENT_PROMPT &&
|
|
1584
|
+
remediationRequestId &&
|
|
1585
|
+
(event.request.policyGateRemediation ||
|
|
1586
|
+
event.request.responseMetadata) &&
|
|
1587
|
+
!clark.context.abortController?.signal.aborted) {
|
|
1588
|
+
await this.failRemediationDispatch(remediationRequestId, event.request.requestId ?? remediationTurnId ?? "", "dispatch_failed", undefined, error);
|
|
1589
|
+
}
|
|
1468
1590
|
if (clark.context.abortController?.signal.aborted &&
|
|
1469
1591
|
event.type !== USER_CANCELED &&
|
|
1470
1592
|
event.type !== AGENT_CANCELED) {
|
|
@@ -1488,14 +1610,26 @@ export class AiService extends TracedEventEmitter {
|
|
|
1488
1610
|
to === ClarkStateNames.Dead) {
|
|
1489
1611
|
// The turn is over: drop per-turn billing attribution so stray
|
|
1490
1612
|
// LLM calls before the next prompt bill as default generation.
|
|
1491
|
-
if (
|
|
1613
|
+
if (generationId === this.currentGenerationId &&
|
|
1614
|
+
(clark.context.usageMetadata || clark.context.baseCommitId)) {
|
|
1492
1615
|
clark.updateContext((context) => ({
|
|
1493
1616
|
...context,
|
|
1494
1617
|
usageMetadata: undefined,
|
|
1495
1618
|
baseCommitId: undefined,
|
|
1496
1619
|
}));
|
|
1497
1620
|
}
|
|
1498
|
-
this.triggerGenerationComplete();
|
|
1621
|
+
this.triggerGenerationComplete(generationId);
|
|
1622
|
+
if (handlerCompleted &&
|
|
1623
|
+
remediationRequestId &&
|
|
1624
|
+
this.ownsRemediationRun(remediationRequestId) &&
|
|
1625
|
+
remediationTurnId &&
|
|
1626
|
+
remediationLlmConfig &&
|
|
1627
|
+
(event.type === APP_RUNTIME_UPDATED_WITHOUT_EDITS ||
|
|
1628
|
+
event.type === V3_AGENT_FINISHED ||
|
|
1629
|
+
event.type === RUNTIME_REVIEW_FINISHED ||
|
|
1630
|
+
event.type === USER_ACCEPTED_DRAFT)) {
|
|
1631
|
+
void this.tryContinuePolicyRemediation(remediationTurnId, remediationLlmConfig);
|
|
1632
|
+
}
|
|
1499
1633
|
}
|
|
1500
1634
|
}
|
|
1501
1635
|
};
|
|
@@ -1540,7 +1674,9 @@ export class AiService extends TracedEventEmitter {
|
|
|
1540
1674
|
*/
|
|
1541
1675
|
async remoteRestoreFallback(id) {
|
|
1542
1676
|
const jwt = this.getInferenceJwt();
|
|
1543
|
-
const branchName =
|
|
1677
|
+
const branchName = jwt
|
|
1678
|
+
? await this._syncContextProvider?.getBranchName()
|
|
1679
|
+
: undefined;
|
|
1544
1680
|
if (!jwt || !branchName) {
|
|
1545
1681
|
// A missing JWT is expected startup churn on a fresh pod (this runs
|
|
1546
1682
|
// before auth is ready), so stay quiet there. Warn on the authed case
|
|
@@ -1865,132 +2001,147 @@ export class AiService extends TracedEventEmitter {
|
|
|
1865
2001
|
recordAutoDispatchOutcome("dead");
|
|
1866
2002
|
return { dispatched: false, reason: "dead" };
|
|
1867
2003
|
}
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
const reason =
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
const reason = this.clark.context.pendingToolPermissionRequest
|
|
1876
|
-
? "pending-tool-permission"
|
|
1877
|
-
: "awaiting-interactive-response";
|
|
2004
|
+
const blocked = this.autoDispatchBlockReason();
|
|
2005
|
+
if (blocked) {
|
|
2006
|
+
const reason = blocked === "busy"
|
|
2007
|
+
? `busy:${this.clark.state}`
|
|
2008
|
+
: blocked === "tool_permission"
|
|
2009
|
+
? "pending-tool-permission"
|
|
2010
|
+
: "awaiting-interactive-response";
|
|
1878
2011
|
recordAutoDispatchOutcome(reason);
|
|
1879
2012
|
return { dispatched: false, reason };
|
|
1880
2013
|
}
|
|
1881
2014
|
this.migrationTurnTriggered = true;
|
|
1882
|
-
|
|
1883
|
-
if (!checklist) {
|
|
1884
|
-
this.migrationTurnTriggered = false;
|
|
1885
|
-
this.getLogger().warn("[ai-service] Migration checklist not found or unreadable; skipping translation turn");
|
|
1886
|
-
recordAutoDispatchOutcome("checklist-unavailable");
|
|
1887
|
-
return { dispatched: false, reason: "checklist-unavailable" };
|
|
1888
|
-
}
|
|
1889
|
-
const pendingSeedApiItems = checklist.items.filter((item) => API_MIGRATION_ORIGINS.has(item.origin) &&
|
|
1890
|
-
(item.status === "pending" || item.status === "in_progress"));
|
|
1891
|
-
const pendingSeedApiCount = pendingSeedApiItems.length;
|
|
1892
|
-
if (pendingSeedApiCount === 0) {
|
|
1893
|
-
this.migrationTurnTriggered = false;
|
|
1894
|
-
recordAutoDispatchOutcome("no-pending-items");
|
|
1895
|
-
return { dispatched: false, reason: "no-pending-items" };
|
|
1896
|
-
}
|
|
1897
|
-
// Pre-flight quota gate. The migration turn drives LLM inference (which
|
|
1898
|
-
// doesn't enforce quota) followed by tool calls that hit quota-protected
|
|
1899
|
-
// endpoints (e.g. attachment uploads via /api/v1/files). Without this gate
|
|
1900
|
-
// an over-limit org burns more credits on the migration turn, then fails
|
|
1901
|
-
// mid-turn with a generic "inference error" when a downstream tool call
|
|
1902
|
-
// 429s. Fail fast with a clear reason before consuming any credits, the
|
|
1903
|
-
// same pre-flight check handleAiGenerate already runs.
|
|
1904
|
-
const quota = await this.checkAiQuota(this.config.inferenceAuthToken ?? request.jwt);
|
|
1905
|
-
if (!quota.allowed) {
|
|
1906
|
-
this.migrationTurnTriggered = false;
|
|
1907
|
-
this.getLogger().warn("[ai-service] Skipping v2→v3 migration turn — AI quota exceeded", { reason: quota.reason });
|
|
1908
|
-
recordAutoDispatchOutcome("quota-exceeded");
|
|
1909
|
-
return { dispatched: false, reason: "quota-exceeded" };
|
|
1910
|
-
}
|
|
1911
|
-
this.appShell.setApiHandlerEnabled(false);
|
|
1912
|
-
this.getLogger().info(`[ai-service] Dispatching internal v2→v3 migration turn for ${pendingSeedApiCount} API(s) (API VFS projection disabled, retryMode=${request.retryMode ?? "none"})`);
|
|
1913
|
-
const prompt = buildTranslationPrompt({
|
|
1914
|
-
focusedDebug: request.retryMode === "focused-debug",
|
|
1915
|
-
});
|
|
1916
|
-
const migrationLlmConfig = this.getMigrationSafeLlmConfig(request.llmConfig);
|
|
1917
|
-
const generateRequest = {
|
|
1918
|
-
prompt,
|
|
1919
|
-
jwt: request.jwt,
|
|
1920
|
-
llmConfig: migrationLlmConfig,
|
|
1921
|
-
mode: AiMode.BUILD,
|
|
1922
|
-
};
|
|
2015
|
+
this.generationStarting = true;
|
|
1923
2016
|
const promptId = randomUUID();
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2017
|
+
let generationStarted = false;
|
|
2018
|
+
try {
|
|
2019
|
+
const checklist = await readPersistedChecklist(this.appRootDirPath);
|
|
2020
|
+
if (!checklist) {
|
|
2021
|
+
this.migrationTurnTriggered = false;
|
|
2022
|
+
this.getLogger().warn("[ai-service] Migration checklist not found or unreadable; skipping translation turn");
|
|
2023
|
+
recordAutoDispatchOutcome("checklist-unavailable");
|
|
2024
|
+
return { dispatched: false, reason: "checklist-unavailable" };
|
|
2025
|
+
}
|
|
2026
|
+
const pendingSeedApiItems = checklist.items.filter((item) => API_MIGRATION_ORIGINS.has(item.origin) &&
|
|
2027
|
+
(item.status === "pending" || item.status === "in_progress"));
|
|
2028
|
+
const pendingSeedApiCount = pendingSeedApiItems.length;
|
|
2029
|
+
if (pendingSeedApiCount === 0) {
|
|
2030
|
+
this.migrationTurnTriggered = false;
|
|
2031
|
+
recordAutoDispatchOutcome("no-pending-items");
|
|
2032
|
+
return { dispatched: false, reason: "no-pending-items" };
|
|
2033
|
+
}
|
|
2034
|
+
// Pre-flight quota gate. The migration turn drives LLM inference (which
|
|
2035
|
+
// doesn't enforce quota) followed by tool calls that hit quota-protected
|
|
2036
|
+
// endpoints (e.g. attachment uploads via /api/v1/files). Without this gate
|
|
2037
|
+
// an over-limit org burns more credits on the migration turn, then fails
|
|
2038
|
+
// mid-turn with a generic "inference error" when a downstream tool call
|
|
2039
|
+
// 429s. Fail fast with a clear reason before consuming any credits, the
|
|
2040
|
+
// same pre-flight check handleAiGenerate already runs.
|
|
2041
|
+
const quota = await this.checkAiQuota(this.config.inferenceAuthToken ?? request.jwt);
|
|
2042
|
+
if (!quota.allowed) {
|
|
2043
|
+
this.migrationTurnTriggered = false;
|
|
2044
|
+
this.getLogger().warn("[ai-service] Skipping v2→v3 migration turn — AI quota exceeded", { reason: quota.reason });
|
|
2045
|
+
recordAutoDispatchOutcome("quota-exceeded");
|
|
2046
|
+
return { dispatched: false, reason: "quota-exceeded" };
|
|
2047
|
+
}
|
|
2048
|
+
await this.config.waitForAppFiles?.(promptId);
|
|
2049
|
+
this.currentGenerationId = promptId;
|
|
2050
|
+
this.appShell.setApiHandlerEnabled(false);
|
|
2051
|
+
this.getLogger().info(`[ai-service] Dispatching internal v2→v3 migration turn for ${pendingSeedApiCount} API(s) (API VFS projection disabled, retryMode=${request.retryMode ?? "none"})`);
|
|
2052
|
+
const prompt = buildTranslationPrompt({
|
|
2053
|
+
focusedDebug: request.retryMode === "focused-debug",
|
|
2054
|
+
});
|
|
2055
|
+
const migrationLlmConfig = this.getMigrationSafeLlmConfig(request.llmConfig);
|
|
2056
|
+
const generateRequest = {
|
|
2057
|
+
prompt,
|
|
2058
|
+
jwt: request.jwt,
|
|
2059
|
+
llmConfig: migrationLlmConfig,
|
|
2060
|
+
mode: AiMode.BUILD,
|
|
2061
|
+
};
|
|
2062
|
+
const retryMode = request.retryMode === "focused-debug" ? "focused-debug" : "none";
|
|
2063
|
+
const triggeredBy = request.triggeredBy ??
|
|
2064
|
+
(request.retryMode === "focused-debug" ? "focused-debug" : "ui-rpc");
|
|
2065
|
+
const branchName = await this._syncContextProvider?.getBranchName();
|
|
2066
|
+
const migrationUsageMetadata = {
|
|
2067
|
+
applicationId: this.config.applicationId,
|
|
2068
|
+
branchName,
|
|
2069
|
+
migrationSource: "superblocks_2_0",
|
|
2070
|
+
origin: CLARK_USAGE_ORIGIN.app_migration,
|
|
2071
|
+
pendingSeedApiCount,
|
|
2072
|
+
retryMode,
|
|
2073
|
+
surface: "app",
|
|
2074
|
+
triggeredBy,
|
|
2075
|
+
};
|
|
2076
|
+
const [baseCommitId] = await Promise.all([
|
|
2077
|
+
this.resolveBaseCommitId(promptId),
|
|
2078
|
+
this.resolveChatSessionId(),
|
|
2079
|
+
]);
|
|
2080
|
+
this.clark.updateContext((context) => ({
|
|
2081
|
+
...context,
|
|
2082
|
+
promptId,
|
|
2083
|
+
promptOutcomeDedupeKey: promptId,
|
|
2084
|
+
baseCommitId,
|
|
2085
|
+
usageMetadata: migrationUsageMetadata,
|
|
2086
|
+
llmConfig: migrationLlmConfig,
|
|
2087
|
+
}));
|
|
2088
|
+
const transitionTo = transitionFrom(this.clark);
|
|
2089
|
+
// Promise is intentionally not awaited — the translation turn runs in the
|
|
2090
|
+
// background. We reset the guard + re-enable the API handler when the
|
|
2091
|
+
// turn *settles* (success or failure) so any remaining pending items
|
|
2092
|
+
// (from auto-retry budget flips or genuine agent exits) can trigger
|
|
2093
|
+
// another sweep without a dev-server restart.
|
|
2094
|
+
let settleOutcome = "success";
|
|
2095
|
+
transitionTo({
|
|
2096
|
+
type: USER_SENT_PROMPT,
|
|
2097
|
+
request: generateRequest,
|
|
2098
|
+
internal: true,
|
|
2099
|
+
visibleInChatHistory: true,
|
|
2100
|
+
})
|
|
2101
|
+
.catch((error) => {
|
|
2102
|
+
settleOutcome = "error";
|
|
2103
|
+
this.getLogger().error("[ai-service] Migration translation turn failed", getErrorMeta(error));
|
|
2104
|
+
})
|
|
2105
|
+
.finally(async () => {
|
|
2106
|
+
this.emit("generationSettled", promptId);
|
|
2107
|
+
try {
|
|
2108
|
+
await recordMigrationTurnSettled(this.appRootDirPath, {
|
|
2109
|
+
outcome: settleOutcome,
|
|
2110
|
+
promptId,
|
|
2111
|
+
retryMode,
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
catch (error) {
|
|
2115
|
+
this.getLogger().error(`[ai-service] Failed to record migration turn settlement (promptId=${promptId})`, getErrorMeta(error));
|
|
2116
|
+
}
|
|
2117
|
+
this.migrationTurnTriggered = false;
|
|
2118
|
+
this.appShell.setApiHandlerEnabled(true);
|
|
2119
|
+
});
|
|
2120
|
+
generationStarted = true;
|
|
1968
2121
|
try {
|
|
1969
|
-
await
|
|
1970
|
-
|
|
2122
|
+
await recordMigrationTurnDispatched(this.appRootDirPath, {
|
|
2123
|
+
pendingSeedApiCount,
|
|
1971
2124
|
promptId,
|
|
1972
2125
|
retryMode,
|
|
2126
|
+
triggeredBy,
|
|
1973
2127
|
});
|
|
1974
2128
|
}
|
|
1975
2129
|
catch (error) {
|
|
1976
|
-
this.getLogger().error(`[ai-service] Failed to record migration turn
|
|
2130
|
+
this.getLogger().error(`[ai-service] Failed to record migration turn dispatch (promptId=${promptId})`, getErrorMeta(error));
|
|
1977
2131
|
}
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
});
|
|
1981
|
-
try {
|
|
1982
|
-
await recordMigrationTurnDispatched(this.appRootDirPath, {
|
|
1983
|
-
pendingSeedApiCount,
|
|
1984
|
-
promptId,
|
|
1985
|
-
retryMode,
|
|
1986
|
-
triggeredBy,
|
|
1987
|
-
});
|
|
2132
|
+
recordAutoDispatchOutcome("dispatched");
|
|
2133
|
+
return { dispatched: true };
|
|
1988
2134
|
}
|
|
1989
|
-
|
|
1990
|
-
this.
|
|
2135
|
+
finally {
|
|
2136
|
+
this.generationStarting = false;
|
|
2137
|
+
if (!generationStarted) {
|
|
2138
|
+
this.migrationTurnTriggered = false;
|
|
2139
|
+
if (this.currentGenerationId === promptId) {
|
|
2140
|
+
this.appShell.setApiHandlerEnabled(true);
|
|
2141
|
+
}
|
|
2142
|
+
this.emit("generationSettled", promptId);
|
|
2143
|
+
}
|
|
1991
2144
|
}
|
|
1992
|
-
recordAutoDispatchOutcome("dispatched");
|
|
1993
|
-
return { dispatched: true };
|
|
1994
2145
|
}
|
|
1995
2146
|
getMigrationSafeLlmConfig(llmConfig) {
|
|
1996
2147
|
const result = filterDisabledToolsForMigration(llmConfig);
|
|
@@ -2024,16 +2175,17 @@ export class AiService extends TracedEventEmitter {
|
|
|
2024
2175
|
* chat record + modal — instead of the interrupt path silently rejecting
|
|
2025
2176
|
* with no UI state. (APPS-4080)
|
|
2026
2177
|
*/
|
|
2027
|
-
async tryHandlePaywallForRequest(request, inputSource) {
|
|
2178
|
+
async tryHandlePaywallForRequest(request, inputSource, internalSignal) {
|
|
2028
2179
|
try {
|
|
2029
2180
|
await this.ensureAiQuotaAvailable(this.config.inferenceAuthToken ?? request.jwt);
|
|
2030
2181
|
return undefined;
|
|
2031
2182
|
}
|
|
2032
2183
|
catch (error) {
|
|
2184
|
+
internalSignal?.throwIfAborted();
|
|
2033
2185
|
if (error instanceof Error) {
|
|
2034
2186
|
const reason = getAiQuotaPaywallReasonFromMessage(error.message);
|
|
2035
2187
|
if (reason) {
|
|
2036
|
-
await this.handlePreCallPaywall(request, reason, inputSource);
|
|
2188
|
+
await this.handlePreCallPaywall(request, reason, inputSource, !!internalSignal);
|
|
2037
2189
|
return reason;
|
|
2038
2190
|
}
|
|
2039
2191
|
}
|
|
@@ -2055,48 +2207,8 @@ export class AiService extends TracedEventEmitter {
|
|
|
2055
2207
|
this.getLogger().warn("[ai-service] Failed to publish user input", getErrorMeta(err));
|
|
2056
2208
|
});
|
|
2057
2209
|
}
|
|
2058
|
-
/**
|
|
2059
|
-
|
|
2060
|
-
* paywall modal when the pre-call quota check denies the request. Mirrors
|
|
2061
|
-
* what `idle.ts:AGENT_CANCELED` does for the mid-stream paywall path so
|
|
2062
|
-
* both paths leave an identical chat record. Used only when
|
|
2063
|
-
* `ensureAiQuotaAvailable` throws a paywall error in `handleAiGenerate`.
|
|
2064
|
-
*
|
|
2065
|
-
* - For organic user prompts (no `responseMetadata`), persists via
|
|
2066
|
-
* `recordUser`.
|
|
2067
|
-
* - For widget responses (`multi_choice_response`, `plan_response`,
|
|
2068
|
-
* `confirm_response`, `searchable_dropdown_response`,
|
|
2069
|
-
* `remember_knowledge_draft_response`), persists via
|
|
2070
|
-
* `recordUserResponse` with the full metadata — without this, the
|
|
2071
|
-
* user's exact choice is silently dropped (they lose their selection
|
|
2072
|
-
* from chat history AND the resume turn's LLM has no record of what
|
|
2073
|
-
* they picked). Mirrors `agent-planning.ts:USER_SENT_PROMPT`.
|
|
2074
|
-
* - `tool_permission_response` is intentionally NOT persisted — those
|
|
2075
|
-
* responses don't have a chat representation; the approval is just
|
|
2076
|
-
* side-effect state. On resume the user will see the permission
|
|
2077
|
-
* prompt again, which is acceptable UX.
|
|
2078
|
-
* - Uses fire-and-forget peer pushes after each `await` on the store so
|
|
2079
|
-
* the chat is durable across refresh even if the live peer push drops.
|
|
2080
|
-
*/
|
|
2081
|
-
async handlePreCallPaywall(request, paywallReason, inputSource) {
|
|
2082
|
-
this.getLogger().info(`[ai-service] Pre-call paywall denial; persisting prompt + advice for chat history`, { paywallReason });
|
|
2083
|
-
// APPS-4080: pre-cache the CANCELLED + paywallReason snapshot into
|
|
2084
|
-
// `lastGenerationState` BEFORE any chat-persistence await. Without
|
|
2085
|
-
// this, a socket reconnect during the awaits in steps 1-2 below
|
|
2086
|
-
// would replay the stale IDLE snapshot held over from before the
|
|
2087
|
-
// request, and the UI saga (`paywallReason && state === CANCELLED`
|
|
2088
|
-
// -> openAiQuotaPaywallModal) would never see the paywall payload —
|
|
2089
|
-
// so the modal would not reopen on refresh. The full
|
|
2090
|
-
// `sendUserGenerationStateChannel({...})` in step 3 below re-caches
|
|
2091
|
-
// and pushes to the peer so the LIVE modal opens AFTER the advice
|
|
2092
|
-
// is in chat. (Cursor PR-19341 review.)
|
|
2093
|
-
this.clark.updateContext({
|
|
2094
|
-
lastGenerationState: {
|
|
2095
|
-
state: AiGenerationState.CANCELLED,
|
|
2096
|
-
hasError: true,
|
|
2097
|
-
paywallReason,
|
|
2098
|
-
},
|
|
2099
|
-
});
|
|
2210
|
+
/** Record a rejected request before emitting a terminal response, without starting generation. */
|
|
2211
|
+
async persistRejectedUserInput(request, inputSource) {
|
|
2100
2212
|
// 1. Persist + echo the user's prompt so the chat doesn't lose it.
|
|
2101
2213
|
const responseMetadata = request.responseMetadata;
|
|
2102
2214
|
if (!responseMetadata) {
|
|
@@ -2116,7 +2228,7 @@ export class AiService extends TracedEventEmitter {
|
|
|
2116
2228
|
});
|
|
2117
2229
|
}
|
|
2118
2230
|
catch (err) {
|
|
2119
|
-
this.getLogger().warn("[ai-service] Failed to persist
|
|
2231
|
+
this.getLogger().warn("[ai-service] Failed to persist rejected user prompt", getErrorMeta(err));
|
|
2120
2232
|
}
|
|
2121
2233
|
}
|
|
2122
2234
|
else if (responseMetadata.type !== "tool_permission_response") {
|
|
@@ -2134,27 +2246,7 @@ export class AiService extends TracedEventEmitter {
|
|
|
2134
2246
|
responseMetadata.selectedChoiceIndices.length === 0) ||
|
|
2135
2247
|
(responseMetadata.type === "plan_response" &&
|
|
2136
2248
|
!responseMetadata.approved);
|
|
2137
|
-
const responseMessage =
|
|
2138
|
-
responseMetadata.selectedChoiceIndices.length > 0
|
|
2139
|
-
? {
|
|
2140
|
-
content: request.prompt,
|
|
2141
|
-
id: randomUUID(),
|
|
2142
|
-
responseToMessageId: responseMetadata.responseToMessageId,
|
|
2143
|
-
role: "user",
|
|
2144
|
-
selectedChoiceIndices: responseMetadata.selectedChoiceIndices,
|
|
2145
|
-
selectedScopes: responseMetadata.selectedScopes,
|
|
2146
|
-
timestamp: Date.now(),
|
|
2147
|
-
type: "multi_choice_response",
|
|
2148
|
-
}
|
|
2149
|
-
: isTypedOverride
|
|
2150
|
-
? {
|
|
2151
|
-
content: request.prompt,
|
|
2152
|
-
id: randomUUID(),
|
|
2153
|
-
role: "user",
|
|
2154
|
-
timestamp: Date.now(),
|
|
2155
|
-
type: "text",
|
|
2156
|
-
}
|
|
2157
|
-
: undefined;
|
|
2249
|
+
const responseMessage = createUserResponseMessage(request.prompt, responseMetadata, inputSource);
|
|
2158
2250
|
if (responseMessage) {
|
|
2159
2251
|
this.publishUserInput(responseMessage, inputSource, isTypedOverride);
|
|
2160
2252
|
}
|
|
@@ -2207,14 +2299,59 @@ export class AiService extends TracedEventEmitter {
|
|
|
2207
2299
|
responseToMessageId: responseMetadata.responseToMessageId,
|
|
2208
2300
|
})
|
|
2209
2301
|
.catch((err) => {
|
|
2210
|
-
this.getLogger().warn("[ai-service] Failed to echo
|
|
2302
|
+
this.getLogger().warn("[ai-service] Failed to echo rejected widget response", getErrorMeta(err));
|
|
2211
2303
|
});
|
|
2212
2304
|
}
|
|
2213
2305
|
}
|
|
2214
2306
|
catch (err) {
|
|
2215
|
-
this.getLogger().warn("[ai-service] Failed to persist
|
|
2307
|
+
this.getLogger().warn("[ai-service] Failed to persist rejected widget response", getErrorMeta(err));
|
|
2216
2308
|
}
|
|
2217
2309
|
}
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Persist the user's prompt + the "credits ran out" advice and open the
|
|
2313
|
+
* paywall modal when the pre-call quota check denies the request. Mirrors
|
|
2314
|
+
* what `idle.ts:AGENT_CANCELED` does for the mid-stream paywall path so
|
|
2315
|
+
* both paths leave an identical chat record. Used only when
|
|
2316
|
+
* `ensureAiQuotaAvailable` throws a paywall error in `handleAiGenerate`.
|
|
2317
|
+
*
|
|
2318
|
+
* - For organic user prompts (no `responseMetadata`), persists via
|
|
2319
|
+
* `recordUser`.
|
|
2320
|
+
* - For widget responses (`multi_choice_response`, `plan_response`,
|
|
2321
|
+
* `confirm_response`, `searchable_dropdown_response`,
|
|
2322
|
+
* `remember_knowledge_draft_response`), persists via
|
|
2323
|
+
* `recordUserResponse` with the full metadata — without this, the
|
|
2324
|
+
* user's exact choice is silently dropped (they lose their selection
|
|
2325
|
+
* from chat history AND the resume turn's LLM has no record of what
|
|
2326
|
+
* they picked). Mirrors `agent-planning.ts:USER_SENT_PROMPT`.
|
|
2327
|
+
* - `tool_permission_response` is intentionally NOT persisted — those
|
|
2328
|
+
* responses don't have a chat representation; the approval is just
|
|
2329
|
+
* side-effect state. On resume the user will see the permission
|
|
2330
|
+
* prompt again, which is acceptable UX.
|
|
2331
|
+
* - Uses fire-and-forget peer pushes after each `await` on the store so
|
|
2332
|
+
* the chat is durable across refresh even if the live peer push drops.
|
|
2333
|
+
*/
|
|
2334
|
+
async handlePreCallPaywall(request, paywallReason, inputSource, internal = false) {
|
|
2335
|
+
this.getLogger().info(`[ai-service] Pre-call paywall denial; persisting prompt + advice for chat history`, { paywallReason });
|
|
2336
|
+
// APPS-4080: pre-cache the CANCELLED + paywallReason snapshot into
|
|
2337
|
+
// `lastGenerationState` BEFORE any chat-persistence await. Without
|
|
2338
|
+
// this, a socket reconnect during the awaits in steps 1-2 below
|
|
2339
|
+
// would replay the stale IDLE snapshot held over from before the
|
|
2340
|
+
// request, and the UI saga (`paywallReason && state === CANCELLED`
|
|
2341
|
+
// -> openAiQuotaPaywallModal) would never see the paywall payload —
|
|
2342
|
+
// so the modal would not reopen on refresh. The full
|
|
2343
|
+
// `sendUserGenerationStateChannel({...})` in step 3 below re-caches
|
|
2344
|
+
// and pushes to the peer so the LIVE modal opens AFTER the advice
|
|
2345
|
+
// is in chat. (Cursor PR-19341 review.)
|
|
2346
|
+
this.clark.updateContext({
|
|
2347
|
+
lastGenerationState: {
|
|
2348
|
+
state: AiGenerationState.CANCELLED,
|
|
2349
|
+
hasError: true,
|
|
2350
|
+
paywallReason,
|
|
2351
|
+
},
|
|
2352
|
+
});
|
|
2353
|
+
if (!internal)
|
|
2354
|
+
await this.persistRejectedUserInput(request, inputSource);
|
|
2218
2355
|
// 2. Persist + echo the credit advice. Same reason-aware copy as
|
|
2219
2356
|
// `agent-planning.ts:LLM_ERRORED` so users see consistent guidance
|
|
2220
2357
|
// across pre-call and mid-stream paywall paths.
|
|
@@ -2284,7 +2421,8 @@ export class AiService extends TracedEventEmitter {
|
|
|
2284
2421
|
// tool_permission_response — those have no persisted chat
|
|
2285
2422
|
// representation; the approval is just side-effect state that gets
|
|
2286
2423
|
// re-prompted on resume.
|
|
2287
|
-
if (
|
|
2424
|
+
if (!internal &&
|
|
2425
|
+
request.responseMetadata?.type !== "tool_permission_response") {
|
|
2288
2426
|
try {
|
|
2289
2427
|
// `getContextId` reads `clark.context.jwt` for the userId, but the
|
|
2290
2428
|
// JWT is normally only stored into clark context by the
|
|
@@ -2347,7 +2485,198 @@ export class AiService extends TracedEventEmitter {
|
|
|
2347
2485
|
}
|
|
2348
2486
|
}
|
|
2349
2487
|
}
|
|
2350
|
-
|
|
2488
|
+
remediationPreparation;
|
|
2489
|
+
remediationAutoDispatch;
|
|
2490
|
+
// Identifies the run owned by the live turn; run data stays on disk.
|
|
2491
|
+
remediationRequestId;
|
|
2492
|
+
ownsRemediationRun(requestId, signal) {
|
|
2493
|
+
return (!signal?.aborted &&
|
|
2494
|
+
!this.clark.context.userInitiatedPromptAbort &&
|
|
2495
|
+
this.remediationRequestId === requestId);
|
|
2496
|
+
}
|
|
2497
|
+
remediationCancellation;
|
|
2498
|
+
remediationChecklistQueue = new OperationQueue();
|
|
2499
|
+
async publishRemediationTurnResult(result) {
|
|
2500
|
+
this.remediationRequestId = result.state?.policyGateRemediation?.requestId;
|
|
2501
|
+
for (const item of result.failedItems) {
|
|
2502
|
+
await this.appShell.sendMessage({
|
|
2503
|
+
type: "checklist_item_updated",
|
|
2504
|
+
payload: { item, totalItems: result.state?.items.length },
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
recordRemediationDispatch(outcome, reason) {
|
|
2509
|
+
metrics
|
|
2510
|
+
.getMeter("superblocks.clark")
|
|
2511
|
+
.createCounter("policy_remediation_dispatch_total", {
|
|
2512
|
+
description: "Automatic remediation dispatch decisions",
|
|
2513
|
+
unit: "1",
|
|
2514
|
+
})
|
|
2515
|
+
.add(1, { outcome, "remediation.reason": reason });
|
|
2516
|
+
}
|
|
2517
|
+
async failRemediationDispatch(requestId, turnId, failureReason = "dispatch_failed", signal, error) {
|
|
2518
|
+
if (!this.ownsRemediationRun(requestId, signal))
|
|
2519
|
+
return;
|
|
2520
|
+
this.recordRemediationDispatch("failed", failureReason);
|
|
2521
|
+
this.getLogger().warnStructured?.("Policy remediation continuation failed", {
|
|
2522
|
+
"application.id": this.config.applicationId,
|
|
2523
|
+
"remediation.request_id": requestId,
|
|
2524
|
+
"remediation.turn_id": turnId,
|
|
2525
|
+
"remediation.failure_reason": failureReason,
|
|
2526
|
+
"error.type": getErrorMeta(error).error.kind,
|
|
2527
|
+
"error.message": getErrorMeta(error).error.message,
|
|
2528
|
+
});
|
|
2529
|
+
const maxAttempts = 3;
|
|
2530
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2531
|
+
if (attempt > 1)
|
|
2532
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt - 1)));
|
|
2533
|
+
if (!this.ownsRemediationRun(requestId, signal))
|
|
2534
|
+
return;
|
|
2535
|
+
try {
|
|
2536
|
+
await this.remediationChecklistQueue.enqueue(async () => {
|
|
2537
|
+
if (!this.ownsRemediationRun(requestId, signal))
|
|
2538
|
+
return;
|
|
2539
|
+
const failed = await failPolicyRemediationDispatch({
|
|
2540
|
+
appRootDirPath: this.appRootDirPath,
|
|
2541
|
+
requestId,
|
|
2542
|
+
});
|
|
2543
|
+
await this.publishRemediationTurnResult(failed);
|
|
2544
|
+
});
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
catch (persistenceError) {
|
|
2548
|
+
this.recordRemediationDispatch("failed", "failure_persistence_failed");
|
|
2549
|
+
this.getLogger().warnStructured?.("Could not persist remediation dispatch failure", {
|
|
2550
|
+
"application.id": this.config.applicationId,
|
|
2551
|
+
"remediation.request_id": requestId,
|
|
2552
|
+
"remediation.turn_id": turnId,
|
|
2553
|
+
"remediation.failure_reason": "failure_persistence_failed",
|
|
2554
|
+
"remediation.persistence_attempt": attempt,
|
|
2555
|
+
"error.type": getErrorMeta(persistenceError).error.kind,
|
|
2556
|
+
"error.message": getErrorMeta(persistenceError).error.message,
|
|
2557
|
+
});
|
|
2558
|
+
}
|
|
2559
|
+
}
|
|
2560
|
+
if (!this.ownsRemediationRun(requestId, signal))
|
|
2561
|
+
return;
|
|
2562
|
+
// Keep the durable owner available for Stop/retry. Never pretend a failed
|
|
2563
|
+
// write committed; notify the live peer even if chat persistence also fails.
|
|
2564
|
+
const message = {
|
|
2565
|
+
id: randomUUID(),
|
|
2566
|
+
role: "assistant",
|
|
2567
|
+
type: "error",
|
|
2568
|
+
timestamp: Date.now(),
|
|
2569
|
+
content: "Security remediation stopped because its progress could not be saved. Restore access to project storage, then stop and retry security remediation.",
|
|
2570
|
+
};
|
|
2571
|
+
const notifications = await Promise.allSettled([
|
|
2572
|
+
this.chatSessionStore.recordAssistant(message),
|
|
2573
|
+
this.clark.context.peer?.call.aiPushMessage(message),
|
|
2574
|
+
]);
|
|
2575
|
+
for (const notification of notifications) {
|
|
2576
|
+
if (notification.status === "rejected")
|
|
2577
|
+
this.getLogger().warn("Could not publish remediation persistence failure", getErrorMeta(notification.reason));
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
async tryContinuePolicyRemediation(turnId, llmConfig) {
|
|
2581
|
+
if (this.remediationAutoDispatch &&
|
|
2582
|
+
!this.remediationAutoDispatch.signal.aborted) {
|
|
2583
|
+
this.recordRemediationDispatch("skipped", "already_dispatching");
|
|
2584
|
+
return;
|
|
2585
|
+
}
|
|
2586
|
+
const requestId = this.remediationRequestId;
|
|
2587
|
+
if (!requestId) {
|
|
2588
|
+
this.recordRemediationDispatch("skipped", "unowned_turn");
|
|
2589
|
+
return;
|
|
2590
|
+
}
|
|
2591
|
+
const controller = new AbortController();
|
|
2592
|
+
this.remediationAutoDispatch = controller;
|
|
2593
|
+
const skipReason = () => {
|
|
2594
|
+
if (controller.signal.aborted ||
|
|
2595
|
+
this.clark.context.userInitiatedPromptAbort)
|
|
2596
|
+
return "cancelled";
|
|
2597
|
+
// Terminal settlement clears the owner; only a replacement invalidates it.
|
|
2598
|
+
if (this.remediationRequestId && !this.ownsRemediationRun(requestId))
|
|
2599
|
+
return "turn_changed";
|
|
2600
|
+
return this.autoDispatchBlockReason();
|
|
2601
|
+
};
|
|
2602
|
+
const shouldSkip = () => {
|
|
2603
|
+
const reason = skipReason();
|
|
2604
|
+
if (reason)
|
|
2605
|
+
this.recordRemediationDispatch("skipped", reason);
|
|
2606
|
+
return reason !== undefined;
|
|
2607
|
+
};
|
|
2608
|
+
let failureReason = "dispatch_failed";
|
|
2609
|
+
try {
|
|
2610
|
+
if (shouldSkip())
|
|
2611
|
+
return;
|
|
2612
|
+
const result = await this.remediationChecklistQueue.enqueue(async () => {
|
|
2613
|
+
if (shouldSkip())
|
|
2614
|
+
return undefined;
|
|
2615
|
+
const result = await advancePolicyRemediationTurn({
|
|
2616
|
+
appRootDirPath: this.appRootDirPath,
|
|
2617
|
+
applicationId: this.config.applicationId,
|
|
2618
|
+
turnId,
|
|
2619
|
+
phase: "settle",
|
|
2620
|
+
requestId,
|
|
2621
|
+
});
|
|
2622
|
+
await this.publishRemediationTurnResult(result);
|
|
2623
|
+
return result;
|
|
2624
|
+
});
|
|
2625
|
+
if (!result || shouldSkip())
|
|
2626
|
+
return;
|
|
2627
|
+
if (result.outcome !== "continue") {
|
|
2628
|
+
// started cannot result from settle, but keep the signal bounded.
|
|
2629
|
+
this.recordRemediationDispatch("skipped", result.outcome === "started" ? "duplicate" : result.outcome);
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
const run = result.state?.policyGateRemediation;
|
|
2633
|
+
if (!run || run.requestId !== requestId) {
|
|
2634
|
+
this.recordRemediationDispatch("skipped", "inactive");
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (!this.clark.context.jwt) {
|
|
2638
|
+
failureReason = "missing_auth";
|
|
2639
|
+
throw new Error("Authentication is required to continue remediation");
|
|
2640
|
+
}
|
|
2641
|
+
const nextTurnId = randomUUID();
|
|
2642
|
+
await this.handleAiGenerate({
|
|
2643
|
+
prompt: "Continue the security fixes. Call getSecurityFindings for the next group.",
|
|
2644
|
+
requestId: nextTurnId,
|
|
2645
|
+
jwt: this.clark.context.jwt,
|
|
2646
|
+
llmConfig,
|
|
2647
|
+
mode: this.clark.context.currentMode,
|
|
2648
|
+
userPreferences: resolveUserPreferences(this.clark.context.userPreferences),
|
|
2649
|
+
policyGateRemediation: {
|
|
2650
|
+
batched: true,
|
|
2651
|
+
reviewFindingIds: run.reviewFindingIds,
|
|
2652
|
+
reviewRunIds: run.reviewRunIds,
|
|
2653
|
+
},
|
|
2654
|
+
}, undefined, controller.signal);
|
|
2655
|
+
if (!this.ownsRemediationRun(requestId, controller.signal)) {
|
|
2656
|
+
this.recordRemediationDispatch("skipped", "cancelled");
|
|
2657
|
+
}
|
|
2658
|
+
else if (this.clark.context.promptOutcomeDedupeKey !== nextTurnId) {
|
|
2659
|
+
if (this.clark.context.pausedForCreditsReason) {
|
|
2660
|
+
this.recordRemediationDispatch("skipped", "paywall");
|
|
2661
|
+
}
|
|
2662
|
+
else {
|
|
2663
|
+
throw new Error("Automatic remediation dispatch was not accepted");
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
catch (error) {
|
|
2668
|
+
if (!this.ownsRemediationRun(requestId, controller.signal)) {
|
|
2669
|
+
this.recordRemediationDispatch("skipped", "cancelled");
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
await this.failRemediationDispatch(requestId, turnId, failureReason, controller.signal, error);
|
|
2673
|
+
}
|
|
2674
|
+
finally {
|
|
2675
|
+
if (this.remediationAutoDispatch === controller)
|
|
2676
|
+
this.remediationAutoDispatch = undefined;
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
async handleAiGenerate(request, inputSource, remediationContinuationSignal) {
|
|
2351
2680
|
this.getLogger().info(`[ai-service] handleAiGenerate: peerId=${this.stablePeer.peerId ?? "unknown"}`);
|
|
2352
2681
|
this.storeMigrationTurnCredentials(request.jwt, request.llmConfig);
|
|
2353
2682
|
if (this.clark.state === ClarkStateNames.Dead) {
|
|
@@ -2364,7 +2693,9 @@ export class AiService extends TracedEventEmitter {
|
|
|
2364
2693
|
return;
|
|
2365
2694
|
}
|
|
2366
2695
|
this.assertNoActivePolicyGateRun();
|
|
2367
|
-
if (this.isBusy()
|
|
2696
|
+
if (this.isBusy() ||
|
|
2697
|
+
this.remediationPreparation ||
|
|
2698
|
+
this.remediationCancellation) {
|
|
2368
2699
|
this.getLogger().warn("[ai-service] Service is busy. State:", this.clark.state);
|
|
2369
2700
|
recordPromptEntryRejection({
|
|
2370
2701
|
dedupeKey: resolvePromptRequestDedupeKey(request),
|
|
@@ -2373,34 +2704,19 @@ export class AiService extends TracedEventEmitter {
|
|
|
2373
2704
|
});
|
|
2374
2705
|
throw new Error("Service is busy");
|
|
2375
2706
|
}
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
//
|
|
2383
|
-
//
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
dedupeKey: resolvePromptRequestDedupeKey(request),
|
|
2390
|
-
failure: promptFailurePaywall(paywallReason),
|
|
2391
|
-
coreModel: request.llmConfig?.coreModel,
|
|
2392
|
-
});
|
|
2393
|
-
return;
|
|
2394
|
-
}
|
|
2395
|
-
if (request.requestId &&
|
|
2396
|
-
!this.requestDeduplicator.markIfNew(request.requestId)) {
|
|
2397
|
-
this.getLogger().warn("[ai-service] Ignoring duplicate aiGenerate re-send (lost dedup race)", { requestId: request.requestId });
|
|
2398
|
-
return;
|
|
2399
|
-
}
|
|
2400
|
-
recordPromptAttempt({
|
|
2401
|
-
outcomeDedupeKey: resolvePromptRequestDedupeKey(request),
|
|
2402
|
-
coreModel: request.llmConfig?.coreModel,
|
|
2403
|
-
});
|
|
2707
|
+
if (request.responseMetadata?.type === "tool_permission_response") {
|
|
2708
|
+
this.getPendingToolPermissionRequest(request.responseMetadata.responseToMessageId);
|
|
2709
|
+
}
|
|
2710
|
+
remediationContinuationSignal?.throwIfAborted();
|
|
2711
|
+
if (!remediationContinuationSignal)
|
|
2712
|
+
this.remediationAutoDispatch?.abort();
|
|
2713
|
+
// Remediation needs a stable identity for its durable owner and budget.
|
|
2714
|
+
// Preserve the existing identity behavior for unrelated legacy requests.
|
|
2715
|
+
if (!request.requestId &&
|
|
2716
|
+
(request.policyGateRemediation ||
|
|
2717
|
+
(request.responseMetadata && this.remediationRequestId)))
|
|
2718
|
+
request = { ...request, requestId: randomUUID() };
|
|
2719
|
+
const promptId = randomUUID();
|
|
2404
2720
|
const requestUsageMetadata = request.importSource
|
|
2405
2721
|
? {
|
|
2406
2722
|
origin: CLARK_USAGE_ORIGIN.app_migration,
|
|
@@ -2411,15 +2727,127 @@ export class AiService extends TracedEventEmitter {
|
|
|
2411
2727
|
const generateStreakEligible = resolvePromptMetricLabels({
|
|
2412
2728
|
usageMetadata: requestUsageMetadata,
|
|
2413
2729
|
}).streakEligible;
|
|
2414
|
-
// The id is now recorded. If anything below throws before generation is
|
|
2415
|
-
// actually kicked off (the `transitionTo` USER_SENT_PROMPT), evict it so a
|
|
2416
|
-
// legitimate reconnect re-send of this same prompt is not swallowed as a
|
|
2417
|
-
// duplicate while nothing is running. (APPS-4663)
|
|
2418
2730
|
let generationTrace;
|
|
2731
|
+
let remediationPreparation;
|
|
2732
|
+
let generationStarted = false;
|
|
2733
|
+
let requestMarked = false;
|
|
2734
|
+
this.generationStarting = true;
|
|
2419
2735
|
try {
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2736
|
+
if (request.requestId) {
|
|
2737
|
+
requestMarked = this.requestDeduplicator.markIfNew(request.requestId);
|
|
2738
|
+
if (!requestMarked) {
|
|
2739
|
+
this.getLogger().warn("[ai-service] Ignoring duplicate aiGenerate re-send (lost dedup race)", { requestId: request.requestId });
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
const remediation = request.policyGateRemediation?.batched === true
|
|
2744
|
+
? request.policyGateRemediation
|
|
2745
|
+
: undefined;
|
|
2746
|
+
if (!remediation &&
|
|
2747
|
+
!request.responseMetadata &&
|
|
2748
|
+
!remediationContinuationSignal) {
|
|
2749
|
+
this.remediationRequestId = undefined;
|
|
2750
|
+
}
|
|
2751
|
+
if (remediation && !remediationContinuationSignal) {
|
|
2752
|
+
// Stop must also cancel preparation while file sync or quota is pending.
|
|
2753
|
+
remediationPreparation = new AbortController();
|
|
2754
|
+
this.remediationPreparation = remediationPreparation;
|
|
2755
|
+
}
|
|
2756
|
+
await this.config.waitForAppFiles?.(promptId);
|
|
2757
|
+
remediationPreparation?.signal.throwIfAborted();
|
|
2758
|
+
remediationContinuationSignal?.throwIfAborted();
|
|
2759
|
+
// Pre-call quota check. When denied, `ensureAiQuotaAvailable` throws so
|
|
2760
|
+
// the UI gets a paywall reason and opens the modal. Without intervention
|
|
2761
|
+
// the throw means we never reach `transitionTo<$AgentPlanning>` below,
|
|
2762
|
+
// so `agent-planning.ts:USER_SENT_PROMPT` never records the user prompt
|
|
2763
|
+
// and `idle.ts:AGENT_CANCELED` never persists the credit advice — the UI
|
|
2764
|
+
// ends up with the optimistic user message removed (thunks.ts catch path)
|
|
2765
|
+
// and no record of what the user tried. Catch it here, persist prompt +
|
|
2766
|
+
// advice + open modal via the same `aiSetGenerationState(CANCELLED,
|
|
2767
|
+
// paywallReason)` saga path the mid-stream paywall uses, then resolve
|
|
2768
|
+
// the socket call so the UI's catch doesn't run. (APPS-4080)
|
|
2769
|
+
const paywallReason = await this.tryHandlePaywallForRequest(request, inputSource, remediationContinuationSignal);
|
|
2770
|
+
remediationPreparation?.signal.throwIfAborted();
|
|
2771
|
+
remediationContinuationSignal?.throwIfAborted();
|
|
2772
|
+
if (paywallReason) {
|
|
2773
|
+
recordPromptEntryRejection({
|
|
2774
|
+
dedupeKey: resolvePromptRequestDedupeKey(request),
|
|
2775
|
+
failure: promptFailurePaywall(paywallReason),
|
|
2776
|
+
coreModel: request.llmConfig?.coreModel,
|
|
2777
|
+
});
|
|
2778
|
+
if (request.responseMetadata?.type === "tool_permission_response") {
|
|
2779
|
+
throw new Error(`${paywallStopAdvice(paywallReason)} [reason:${paywallReason}]`);
|
|
2780
|
+
}
|
|
2781
|
+
return;
|
|
2782
|
+
}
|
|
2783
|
+
recordPromptAttempt({
|
|
2784
|
+
outcomeDedupeKey: resolvePromptRequestDedupeKey(request),
|
|
2785
|
+
coreModel: request.llmConfig?.coreModel,
|
|
2786
|
+
});
|
|
2787
|
+
if (remediation && remediationPreparation) {
|
|
2788
|
+
const preparation = remediationPreparation;
|
|
2789
|
+
const existing = await readPersistedChecklist(this.appRootDirPath);
|
|
2790
|
+
if (request.requestId &&
|
|
2791
|
+
existing?.items.some((item) => item.remediationRequestId === request.requestId)) {
|
|
2792
|
+
// A restart or failed generation start may leave a persisted run.
|
|
2793
|
+
// Resume pending work without reseeding or cancelling its groups.
|
|
2794
|
+
if (!existing.items.some((item) => item.remediationRequestId === request.requestId &&
|
|
2795
|
+
(item.status === "pending" || item.status === "in_progress"))) {
|
|
2796
|
+
this.getLogger().info("[ai-service] Ignoring finished remediation request replay", { requestId: request.requestId });
|
|
2797
|
+
recordPromptEntryRejection({
|
|
2798
|
+
dedupeKey: resolvePromptRequestDedupeKey(request),
|
|
2799
|
+
failure: promptFailurePlatform("request_already_finished"),
|
|
2800
|
+
coreModel: request.llmConfig?.coreModel,
|
|
2801
|
+
});
|
|
2802
|
+
await sendUserGenerationStateChannel(this.clark)({
|
|
2803
|
+
state: AiGenerationState.CANCELLED,
|
|
2804
|
+
});
|
|
2805
|
+
return;
|
|
2806
|
+
}
|
|
2807
|
+
preparation.signal.throwIfAborted();
|
|
2808
|
+
this.remediationRequestId = existing.policyGateRemediation?.requestId;
|
|
2809
|
+
}
|
|
2810
|
+
else {
|
|
2811
|
+
preparation.signal.throwIfAborted();
|
|
2812
|
+
const findings = await fetchRemediationFindings({
|
|
2813
|
+
rpcClient: this.config.rpcClient,
|
|
2814
|
+
applicationId: this.config.applicationId,
|
|
2815
|
+
reviewFindingIds: remediation.reviewFindingIds,
|
|
2816
|
+
signal: preparation.signal,
|
|
2817
|
+
});
|
|
2818
|
+
preparation.signal.throwIfAborted();
|
|
2819
|
+
await this.remediationChecklistQueue.enqueue(async () => {
|
|
2820
|
+
preparation.signal.throwIfAborted();
|
|
2821
|
+
const seeded = await seedPolicyRemediationChecklist({
|
|
2822
|
+
appRootDirPath: this.appRootDirPath,
|
|
2823
|
+
requestId: request.requestId ?? promptId,
|
|
2824
|
+
remediation,
|
|
2825
|
+
findings,
|
|
2826
|
+
});
|
|
2827
|
+
// Transfer ownership only after the replacement is durable. Stop
|
|
2828
|
+
// reads it from this same queue, including during the seed write.
|
|
2829
|
+
this.remediationRequestId = request.requestId ?? promptId;
|
|
2830
|
+
for (const item of seeded.cancelledItems) {
|
|
2831
|
+
await this.appShell.sendMessage({
|
|
2832
|
+
type: "checklist_item_updated",
|
|
2833
|
+
payload: { item, totalItems: seeded.state.items.length },
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2836
|
+
for (const item of seeded.addedItems) {
|
|
2837
|
+
// Stop publishes these rows as cancelled from the same queue.
|
|
2838
|
+
// Still publish the prior run's cancelledItems above.
|
|
2839
|
+
if (preparation.signal.aborted)
|
|
2840
|
+
break;
|
|
2841
|
+
await this.appShell.sendMessage({
|
|
2842
|
+
type: "checklist_item_added",
|
|
2843
|
+
payload: { item, totalItems: seeded.state.items.length },
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
preparation.signal.throwIfAborted();
|
|
2849
|
+
}
|
|
2850
|
+
this.currentGenerationId = promptId;
|
|
2423
2851
|
generationTrace = this.startGenerationTrace(request, promptId);
|
|
2424
2852
|
const activeGenerationTrace = generationTrace;
|
|
2425
2853
|
this.clarkProfiler.startSession();
|
|
@@ -2442,6 +2870,14 @@ export class AiService extends TracedEventEmitter {
|
|
|
2442
2870
|
this.resolveBaseCommitId(promptId),
|
|
2443
2871
|
this.resolveChatSessionId(),
|
|
2444
2872
|
]);
|
|
2873
|
+
if (request.responseMetadata?.type === "tool_permission_response") {
|
|
2874
|
+
if (this.isClarkMidTurn()) {
|
|
2875
|
+
throw new Error("Service is busy");
|
|
2876
|
+
}
|
|
2877
|
+
this.getPendingToolPermissionRequest(request.responseMetadata.responseToMessageId);
|
|
2878
|
+
}
|
|
2879
|
+
this.remediationPreparation?.signal.throwIfAborted();
|
|
2880
|
+
remediationContinuationSignal?.throwIfAborted();
|
|
2445
2881
|
this.clark.updateContext((context) => {
|
|
2446
2882
|
return {
|
|
2447
2883
|
...context,
|
|
@@ -2473,15 +2909,19 @@ export class AiService extends TracedEventEmitter {
|
|
|
2473
2909
|
userPreferences: request.userPreferences,
|
|
2474
2910
|
};
|
|
2475
2911
|
});
|
|
2912
|
+
const generationRemediationRequestId = this.remediationRequestId;
|
|
2476
2913
|
const transitionTo = transitionFrom(this.clark);
|
|
2477
2914
|
void otelContext
|
|
2478
2915
|
.with(activeGenerationTrace.context, () => Promise.resolve(transitionTo({
|
|
2479
2916
|
type: USER_SENT_PROMPT,
|
|
2480
2917
|
request,
|
|
2481
2918
|
inputSource,
|
|
2919
|
+
executeToolPermissionResponse: request.responseMetadata?.type === "tool_permission_response",
|
|
2920
|
+
internal: remediationContinuationSignal ? true : undefined,
|
|
2482
2921
|
})))
|
|
2483
|
-
.catch((error) => {
|
|
2922
|
+
.catch(async (error) => {
|
|
2484
2923
|
activeGenerationTrace.finish(error);
|
|
2924
|
+
this.emit("generationSettled", promptId);
|
|
2485
2925
|
if (request.requestId) {
|
|
2486
2926
|
this.requestDeduplicator.forget(request.requestId);
|
|
2487
2927
|
}
|
|
@@ -2496,23 +2936,35 @@ export class AiService extends TracedEventEmitter {
|
|
|
2496
2936
|
this.getLogger().error("[ai-service] Failed to start generation", {
|
|
2497
2937
|
error,
|
|
2498
2938
|
});
|
|
2939
|
+
if (generationRemediationRequestId) {
|
|
2940
|
+
await this.failRemediationDispatch(generationRemediationRequestId, promptOutcomeDedupeKey, "dispatch_failed", undefined, error);
|
|
2941
|
+
}
|
|
2499
2942
|
});
|
|
2943
|
+
generationStarted = true;
|
|
2500
2944
|
}
|
|
2501
2945
|
catch (error) {
|
|
2502
2946
|
generationTrace?.finish(error);
|
|
2503
|
-
if (request.requestId) {
|
|
2504
|
-
this.requestDeduplicator.forget(request.requestId);
|
|
2505
|
-
}
|
|
2506
2947
|
recordPromptOutcome({
|
|
2507
2948
|
dedupeKey: resolvePromptRequestDedupeKey(request),
|
|
2508
2949
|
outcome: "failure",
|
|
2509
2950
|
failure: promptFailurePlatform("handler_error"),
|
|
2510
2951
|
coreModel: request.llmConfig?.coreModel,
|
|
2511
|
-
promptId
|
|
2952
|
+
promptId,
|
|
2512
2953
|
streakEligible: generateStreakEligible,
|
|
2513
2954
|
});
|
|
2514
2955
|
throw error;
|
|
2515
2956
|
}
|
|
2957
|
+
finally {
|
|
2958
|
+
if (this.remediationPreparation === remediationPreparation)
|
|
2959
|
+
this.remediationPreparation = undefined;
|
|
2960
|
+
this.generationStarting = false;
|
|
2961
|
+
if (!generationStarted) {
|
|
2962
|
+
if (requestMarked && request.requestId) {
|
|
2963
|
+
this.requestDeduplicator.forget(request.requestId);
|
|
2964
|
+
}
|
|
2965
|
+
this.emit("generationSettled", promptId);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2516
2968
|
}
|
|
2517
2969
|
async handleAiSummarizeApiUsage(request) {
|
|
2518
2970
|
if (this.clark.state === ClarkStateNames.Dead) {
|
|
@@ -3066,67 +3518,103 @@ export class AiService extends TracedEventEmitter {
|
|
|
3066
3518
|
: "User closed the integration setup form. The integration setup might not have been completed successfully.");
|
|
3067
3519
|
}
|
|
3068
3520
|
async handleUserCanceled(reason) {
|
|
3521
|
+
if (this.remediationCancellation)
|
|
3522
|
+
return this.remediationCancellation;
|
|
3523
|
+
const busy = this.isBusy();
|
|
3524
|
+
const awaitingUser = this.isAwaitingUser();
|
|
3525
|
+
const cancelledContext = { ...this.clark.context };
|
|
3526
|
+
const cancelRemediation = this.remediationRequestId || this.remediationPreparation;
|
|
3527
|
+
this.remediationAutoDispatch?.abort(createAbortReason("user-cancel"));
|
|
3069
3528
|
if (reason === "stop")
|
|
3070
3529
|
this.reportAbandonTurn(reason);
|
|
3071
3530
|
// Drop parked/in-flight permission prompts first so interval replay and
|
|
3072
3531
|
// grace expiry cannot keep pushing the dialog or inject a continuation
|
|
3073
3532
|
// after the user has already stopped the turn.
|
|
3074
3533
|
clearPendingToolPermissionRequest(this.clark, "user-canceled");
|
|
3075
|
-
|
|
3076
|
-
if (
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3534
|
+
this.remediationPreparation?.abort(createAbortReason("user-cancel"));
|
|
3535
|
+
if (busy || awaitingUser) {
|
|
3536
|
+
this.clark.updateContext({ userInitiatedPromptAbort: true });
|
|
3537
|
+
this.clark.context.abortController?.abort(createAbortReason("user-cancel"));
|
|
3538
|
+
}
|
|
3539
|
+
if (cancelRemediation) {
|
|
3540
|
+
this.remediationCancellation = this.remediationChecklistQueue.enqueue(async () => {
|
|
3541
|
+
const remediationRequestId = this.remediationRequestId;
|
|
3542
|
+
if (!remediationRequestId)
|
|
3543
|
+
return;
|
|
3544
|
+
const cancelledItems = await cancelPolicyRemediation(this.appRootDirPath, remediationRequestId);
|
|
3545
|
+
this.remediationRequestId = undefined;
|
|
3546
|
+
for (const item of cancelledItems) {
|
|
3547
|
+
try {
|
|
3548
|
+
await this.appShell.sendMessage({
|
|
3549
|
+
type: "checklist_item_updated",
|
|
3550
|
+
payload: { item },
|
|
3551
|
+
});
|
|
3552
|
+
}
|
|
3553
|
+
catch (error) {
|
|
3554
|
+
this.getLogger().error("[ai-service] Failed to publish remediation cancellation", getErrorMeta(error));
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3098
3557
|
});
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3558
|
+
}
|
|
3559
|
+
try {
|
|
3560
|
+
if (!busy && !awaitingUser) {
|
|
3561
|
+
return;
|
|
3562
|
+
}
|
|
3563
|
+
if (busy) {
|
|
3564
|
+
const { promptId, workflowMetrics } = cancelledContext;
|
|
3565
|
+
const labels = resolvePromptMetricLabels(cancelledContext);
|
|
3566
|
+
// Fire-and-forget: calculatePromptCost has no timeout and reconnect can
|
|
3567
|
+
// sleep for seconds. Cancel teardown must not wait on a billing RPC —
|
|
3568
|
+
// the in-flight map already dedupes a racing Idle handler, and locals
|
|
3569
|
+
// above survive the context clear below.
|
|
3570
|
+
void finalizePromptBillingMetrics({
|
|
3571
|
+
chatSessionStore: this.chatSessionStore,
|
|
3572
|
+
labels,
|
|
3573
|
+
logger: this.getLogger(),
|
|
3574
|
+
promptId,
|
|
3575
|
+
promptUsageAlarmThreshold: this.config.features?.promptUsageAlarmThreshold,
|
|
3576
|
+
workflowMetrics,
|
|
3577
|
+
});
|
|
3578
|
+
recordPromptOutcome({
|
|
3579
|
+
dedupeKey: resolvePromptOutcomeDedupeKey(cancelledContext),
|
|
3580
|
+
outcome: "failure",
|
|
3581
|
+
failure: promptFailureUserCanceled(),
|
|
3582
|
+
promptId: cancelledContext.promptId,
|
|
3583
|
+
...labels,
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
if (awaitingUser && cancelledContext.awaitingUserHandoffRecorded) {
|
|
3587
|
+
recordAwaitingUserHandoffDismissed({
|
|
3588
|
+
awaitingUserHandoffPromptId: cancelledContext.awaitingUserHandoffPromptId,
|
|
3589
|
+
awaitingUserHandoffRecorded: cancelledContext.awaitingUserHandoffRecorded,
|
|
3590
|
+
promptId: cancelledContext.promptId,
|
|
3591
|
+
promptOutcomeDedupeKey: cancelledContext.promptOutcomeDedupeKey,
|
|
3592
|
+
reason: "cancel",
|
|
3593
|
+
});
|
|
3594
|
+
}
|
|
3595
|
+
// Clear the prompt_id since this prompt was abandoned
|
|
3596
|
+
this.clark.updateContext((context) => ({
|
|
3597
|
+
...context,
|
|
3598
|
+
promptId: undefined,
|
|
3599
|
+
promptOutcomeDedupeKey: undefined,
|
|
3600
|
+
usageMetadata: undefined,
|
|
3601
|
+
baseCommitId: undefined,
|
|
3602
|
+
awaitingUserHandoffPromptId: undefined,
|
|
3603
|
+
awaitingUserHandoffRecorded: undefined,
|
|
3604
|
+
}));
|
|
3605
|
+
void this.clark.send({
|
|
3606
|
+
type: USER_CANCELED,
|
|
3102
3607
|
failure: promptFailureUserCanceled(),
|
|
3103
|
-
promptId: this.clark.context.promptId,
|
|
3104
|
-
...labels,
|
|
3105
3608
|
});
|
|
3106
3609
|
}
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
});
|
|
3610
|
+
finally {
|
|
3611
|
+
try {
|
|
3612
|
+
await this.remediationCancellation;
|
|
3613
|
+
}
|
|
3614
|
+
finally {
|
|
3615
|
+
this.remediationCancellation = undefined;
|
|
3616
|
+
}
|
|
3115
3617
|
}
|
|
3116
|
-
// Clear the prompt_id since this prompt was abandoned
|
|
3117
|
-
this.clark.updateContext((context) => ({
|
|
3118
|
-
...context,
|
|
3119
|
-
promptId: undefined,
|
|
3120
|
-
promptOutcomeDedupeKey: undefined,
|
|
3121
|
-
usageMetadata: undefined,
|
|
3122
|
-
baseCommitId: undefined,
|
|
3123
|
-
awaitingUserHandoffPromptId: undefined,
|
|
3124
|
-
awaitingUserHandoffRecorded: undefined,
|
|
3125
|
-
}));
|
|
3126
|
-
void this.clark.send({
|
|
3127
|
-
type: USER_CANCELED,
|
|
3128
|
-
failure: promptFailureUserCanceled(),
|
|
3129
|
-
});
|
|
3130
3618
|
}
|
|
3131
3619
|
/**
|
|
3132
3620
|
* UI-initiated cancellation of a background task. Delegates to
|
|
@@ -3201,6 +3689,9 @@ export class AiService extends TracedEventEmitter {
|
|
|
3201
3689
|
await this.handleAiGenerate(request, inputSource);
|
|
3202
3690
|
return { generationId: this.clark.context.promptId, status: "started" };
|
|
3203
3691
|
}
|
|
3692
|
+
if (request.responseMetadata?.type === "tool_permission_response") {
|
|
3693
|
+
throw new Error("Service is busy");
|
|
3694
|
+
}
|
|
3204
3695
|
await this.interruptAndContinue(request, inputSource);
|
|
3205
3696
|
return {
|
|
3206
3697
|
generationId: this.clark.context.promptId,
|
|
@@ -3392,11 +3883,19 @@ export class AiService extends TracedEventEmitter {
|
|
|
3392
3883
|
if (!(await this.waitUntilToolPermissionContinuationAllowed(messageId))) {
|
|
3393
3884
|
return;
|
|
3394
3885
|
}
|
|
3886
|
+
// A reconnect during the wait above moves the deadline and may still
|
|
3887
|
+
// be replaying this prompt. Expiring from the old timer would write a
|
|
3888
|
+
// delivery error for the message id that replay is delivering.
|
|
3889
|
+
await inFlightToolPermissionReplay(this.clark);
|
|
3395
3890
|
const expired = this.clark.context.pendingToolPermissionRequest;
|
|
3396
3891
|
if (expired?.messageId !== messageId ||
|
|
3397
3892
|
expired.delivery.status !== "parked") {
|
|
3398
3893
|
return;
|
|
3399
3894
|
}
|
|
3895
|
+
if (Date.now() < expired.delivery.expiresAtMs) {
|
|
3896
|
+
this.scheduleToolPermissionExpiry(messageId);
|
|
3897
|
+
return;
|
|
3898
|
+
}
|
|
3400
3899
|
const error = createExpiredPermissionPromptDeliveryError(expired.toolName, expired.delivery);
|
|
3401
3900
|
recordToolPermissionDeliveryOutcome("expired");
|
|
3402
3901
|
await this.handleToolPermissionOutcome({
|
|
@@ -3409,17 +3908,12 @@ export class AiService extends TracedEventEmitter {
|
|
|
3409
3908
|
});
|
|
3410
3909
|
}, expiresInMs);
|
|
3411
3910
|
}
|
|
3412
|
-
/**
|
|
3413
|
-
* `userSentPrompt` is only valid from Idle or AwaitingUser. A parked
|
|
3414
|
-
* permission can expire while a sibling tool is still generating; wait
|
|
3415
|
-
* until the continuation can actually run, or abort if the request is gone.
|
|
3416
|
-
*/
|
|
3911
|
+
/** Wait for a pending permission decision to become a valid continuation. */
|
|
3417
3912
|
async waitUntilToolPermissionContinuationAllowed(messageId) {
|
|
3418
3913
|
const TOOL_PERMISSION_CONTINUATION_POLL_MS = 250;
|
|
3419
3914
|
for (;;) {
|
|
3420
3915
|
const current = this.clark.context.pendingToolPermissionRequest;
|
|
3421
|
-
if (current?.messageId !== messageId
|
|
3422
|
-
current.delivery.status !== "parked") {
|
|
3916
|
+
if (current?.messageId !== messageId) {
|
|
3423
3917
|
return false;
|
|
3424
3918
|
}
|
|
3425
3919
|
switch (this.clark.state) {
|
|
@@ -3449,12 +3943,18 @@ export class AiService extends TracedEventEmitter {
|
|
|
3449
3943
|
const pending = this.clark.context.pendingToolPermissionRequest;
|
|
3450
3944
|
if (pending?.messageId !== messageId ||
|
|
3451
3945
|
pending.delivery.status !== "parked" ||
|
|
3452
|
-
|
|
3946
|
+
this.clark.state === ClarkStateNames.Dead) {
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
if (Date.now() >= pending.delivery.expiresAtMs) {
|
|
3950
|
+
// Expiry can wait for Clark to become idle. Keep this retry loop armed
|
|
3951
|
+
// so a reconnect that renews the deadline can resume delivery.
|
|
3952
|
+
this.scheduleToolPermissionReplay(messageId);
|
|
3453
3953
|
return;
|
|
3454
3954
|
}
|
|
3455
|
-
const replay = this.
|
|
3456
|
-
|
|
3457
|
-
|
|
3955
|
+
const replay = flushPendingToolPermissionPrompt(this.clark, {
|
|
3956
|
+
source: "retry",
|
|
3957
|
+
});
|
|
3458
3958
|
void replay.finally(() => {
|
|
3459
3959
|
const current = this.clark.context.pendingToolPermissionRequest;
|
|
3460
3960
|
if (current?.messageId === messageId &&
|
|
@@ -3487,26 +3987,97 @@ export class AiService extends TracedEventEmitter {
|
|
|
3487
3987
|
});
|
|
3488
3988
|
return;
|
|
3489
3989
|
}
|
|
3990
|
+
const pendingRequest = this.getPendingToolPermissionRequest(request.messageId);
|
|
3991
|
+
if (this.clark.state === ClarkStateNames.Dead) {
|
|
3992
|
+
throw new Error("Service is unavailable");
|
|
3993
|
+
}
|
|
3994
|
+
// Only yield while Clark is mid-generation: awaiting in the common case
|
|
3995
|
+
// would let a concurrent continuation pass the busy check below before
|
|
3996
|
+
// this one claims `generationStarting`.
|
|
3997
|
+
if (this.clark.state !== ClarkStateNames.Idle &&
|
|
3998
|
+
this.clark.state !== ClarkStateNames.AwaitingUser &&
|
|
3999
|
+
!(await this.waitUntilToolPermissionContinuationAllowed(request.messageId))) {
|
|
4000
|
+
return;
|
|
4001
|
+
}
|
|
4002
|
+
if (this.isBusy()) {
|
|
4003
|
+
throw new Error("Service is busy");
|
|
4004
|
+
}
|
|
4005
|
+
const promptOutcomeDedupeKey = randomUUID();
|
|
4006
|
+
let generationStarted = false;
|
|
4007
|
+
this.generationStarting = true;
|
|
4008
|
+
try {
|
|
4009
|
+
await this.config.waitForAppFiles?.(promptOutcomeDedupeKey);
|
|
4010
|
+
this.currentGenerationId = promptOutcomeDedupeKey;
|
|
4011
|
+
// The response is now accepted (matches the pending request). Record its id
|
|
4012
|
+
// and honor the result so a re-send dropped here can't be applied twice. A
|
|
4013
|
+
// duplicate that loses the atomic `markIfNew` bails before the tool runs.
|
|
4014
|
+
// (APPS-4663)
|
|
4015
|
+
if (request.kind === "response" &&
|
|
4016
|
+
request.requestId &&
|
|
4017
|
+
!this.requestDeduplicator.markIfNew(request.requestId)) {
|
|
4018
|
+
logger.warn("[ai-service] Ignoring duplicate tool-permission re-send (lost dedup race)", { requestId: request.requestId });
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
const continueRequest = await this.resolveToolPermissionOutcome(request, pendingRequest);
|
|
4022
|
+
if (!continueRequest) {
|
|
4023
|
+
return;
|
|
4024
|
+
}
|
|
4025
|
+
this.clark.updateContext({ promptOutcomeDedupeKey });
|
|
4026
|
+
recordPromptAttempt({
|
|
4027
|
+
coreModel: this.clark.context.llmConfig?.coreModel,
|
|
4028
|
+
entrypoint: PROMPT_ATTEMPT_ENTRYPOINT.tool_permission_continuation,
|
|
4029
|
+
outcomeDedupeKey: promptOutcomeDedupeKey,
|
|
4030
|
+
});
|
|
4031
|
+
const transitionTo = transitionFrom(this.clark);
|
|
4032
|
+
void Promise.resolve(transitionTo({
|
|
4033
|
+
type: USER_SENT_PROMPT,
|
|
4034
|
+
request: { ...continueRequest, requestId: promptOutcomeDedupeKey },
|
|
4035
|
+
})).catch((error) => {
|
|
4036
|
+
this.emit("generationSettled", promptOutcomeDedupeKey);
|
|
4037
|
+
logger.error("[ai-service] Failed to continue after tool permission", {
|
|
4038
|
+
error,
|
|
4039
|
+
});
|
|
4040
|
+
});
|
|
4041
|
+
generationStarted = true;
|
|
4042
|
+
}
|
|
4043
|
+
finally {
|
|
4044
|
+
this.generationStarting = false;
|
|
4045
|
+
if (!generationStarted) {
|
|
4046
|
+
this.emit("generationSettled", promptOutcomeDedupeKey);
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
getPendingToolPermissionRequest(messageId) {
|
|
3490
4051
|
const pendingRequest = this.clark.context.pendingToolPermissionRequest;
|
|
3491
|
-
logger.info(`[ai-service] Pending request: ${JSON.stringify(pendingRequest)}`);
|
|
3492
4052
|
if (!pendingRequest) {
|
|
3493
|
-
logger.warn(`[ai-service] No pending tool permission request found for messageId=${request.messageId}`);
|
|
3494
4053
|
throw new Error("No pending tool permission request");
|
|
3495
4054
|
}
|
|
3496
|
-
if (pendingRequest.messageId !==
|
|
3497
|
-
logger.warn(`[ai-service] Message ID mismatch: expected=${pendingRequest.messageId}, got=${request.messageId}`);
|
|
4055
|
+
if (pendingRequest.messageId !== messageId) {
|
|
3498
4056
|
throw new Error("Message ID mismatch");
|
|
3499
4057
|
}
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
if (
|
|
3505
|
-
|
|
3506
|
-
!this.requestDeduplicator.markIfNew(request.requestId)) {
|
|
3507
|
-
logger.warn("[ai-service] Ignoring duplicate tool-permission re-send (lost dedup race)", { requestId: request.requestId });
|
|
3508
|
-
return;
|
|
4058
|
+
return pendingRequest;
|
|
4059
|
+
}
|
|
4060
|
+
async resolveToolPermissionResponse(request) {
|
|
4061
|
+
const metadata = request.responseMetadata;
|
|
4062
|
+
if (metadata?.type !== "tool_permission_response") {
|
|
4063
|
+
throw new Error("Expected a tool permission response");
|
|
3509
4064
|
}
|
|
4065
|
+
const pendingRequest = this.getPendingToolPermissionRequest(metadata.responseToMessageId);
|
|
4066
|
+
return this.resolveToolPermissionOutcome({
|
|
4067
|
+
kind: "response",
|
|
4068
|
+
messageId: metadata.responseToMessageId,
|
|
4069
|
+
approved: metadata.approved === true,
|
|
4070
|
+
alwaysAllowPermissions: metadata.alwaysAllowPermissions,
|
|
4071
|
+
denyMessage: metadata.approved ? undefined : request.prompt,
|
|
4072
|
+
}, pendingRequest, metadata.clarification === true);
|
|
4073
|
+
}
|
|
4074
|
+
async resolveToolPermissionOutcome(request, pendingRequest, clarification) {
|
|
4075
|
+
const logger = this.getLogger();
|
|
4076
|
+
const approved = request.kind === "response" && request.approved;
|
|
4077
|
+
const { promptId, promptOutcomeDedupeKey, abortController } = this.clark.context;
|
|
4078
|
+
const isCurrentResponse = () => !abortController?.signal.aborted &&
|
|
4079
|
+
this.clark.context.promptId === promptId &&
|
|
4080
|
+
this.clark.context.promptOutcomeDedupeKey === promptOutcomeDedupeKey;
|
|
3510
4081
|
logger.info(`[ai-service] Clearing pending request`);
|
|
3511
4082
|
// Clear the pending request and notify UI
|
|
3512
4083
|
clearPendingToolPermissionRequest(this.clark, request.kind === "delivery_error"
|
|
@@ -3530,12 +4101,21 @@ export class AiService extends TracedEventEmitter {
|
|
|
3530
4101
|
},
|
|
3531
4102
|
],
|
|
3532
4103
|
};
|
|
3533
|
-
|
|
4104
|
+
const chatSessionId = this.chatSessionStore.getChatSessionId();
|
|
4105
|
+
const isClarification = clarification ??
|
|
4106
|
+
isToolPermissionClarification(approved, request.kind === "response" ? request.denyMessage : undefined);
|
|
4107
|
+
let outcome;
|
|
3534
4108
|
if (approved) {
|
|
3535
|
-
// Approve in entity permission store
|
|
3536
|
-
this.entityPermissionStore.approveToolCall(toolName, rawInput);
|
|
3537
4109
|
const signature = getToolCallSignature(toolName, rawInput);
|
|
3538
4110
|
const formattedArgs = await getToolCallArguments(toolName, rawInput, this.clark);
|
|
4111
|
+
// The answer stays consumed: whatever superseded it (a cancel or a
|
|
4112
|
+
// newer prompt) also retires the request it answered.
|
|
4113
|
+
if (!isCurrentResponse()) {
|
|
4114
|
+
recordToolPermissionLateOutcome("skipped");
|
|
4115
|
+
logger.warn("[ai-service] An approved tool permission was superseded before its tool ran", { toolCallId, toolName });
|
|
4116
|
+
return;
|
|
4117
|
+
}
|
|
4118
|
+
this.entityPermissionStore.approveToolCall(toolName, rawInput);
|
|
3539
4119
|
logger.info(`[ai-service] Sending tool call message: tool=${toolName}, toolCallId=${toolCallId}, signature=${signature}`);
|
|
3540
4120
|
void sendUserMessage({
|
|
3541
4121
|
role: "assistant",
|
|
@@ -3552,116 +4132,19 @@ export class AiService extends TracedEventEmitter {
|
|
|
3552
4132
|
void this.entityPermissionStore.approve(permission.entityType, permission.entityId, permission.permissionType);
|
|
3553
4133
|
});
|
|
3554
4134
|
}
|
|
3555
|
-
|
|
3556
|
-
const accessControlContext = this.clark.getAccessControl(this.config.features);
|
|
3557
|
-
logger.info(`[ai-service] Executing approved tool: ${toolName}`);
|
|
3558
|
-
logger.info(`[ai-service] Access control context: mode=${accessControlContext.mode}`);
|
|
3559
|
-
try {
|
|
3560
|
-
const result = await this.toolRegistry.execute(toolName, rawInput, accessControlContext, { toolCallId, messages: [] });
|
|
3561
|
-
if (result.type === "success") {
|
|
3562
|
-
toolResultMessage = {
|
|
3563
|
-
role: "tool",
|
|
3564
|
-
content: [
|
|
3565
|
-
{
|
|
3566
|
-
type: "tool-result",
|
|
3567
|
-
toolCallId,
|
|
3568
|
-
toolName,
|
|
3569
|
-
output: { type: "json", value: result.data },
|
|
3570
|
-
},
|
|
3571
|
-
],
|
|
3572
|
-
};
|
|
3573
|
-
// Use getToolOutput for consistent formatting with processStreamChunk
|
|
3574
|
-
const output = getToolOutput(toolName, result.data);
|
|
3575
|
-
void sendUserMessage({
|
|
3576
|
-
role: "assistant",
|
|
3577
|
-
content: "",
|
|
3578
|
-
type: "tool-result",
|
|
3579
|
-
toolCallId,
|
|
3580
|
-
toolName,
|
|
3581
|
-
output,
|
|
3582
|
-
});
|
|
3583
|
-
}
|
|
3584
|
-
else {
|
|
3585
|
-
logger.warn(`[ai-service] Tool ${toolName} execution failed: ${result.error.message}`);
|
|
3586
|
-
toolResultMessage = {
|
|
3587
|
-
role: "tool",
|
|
3588
|
-
content: [
|
|
3589
|
-
{
|
|
3590
|
-
type: "tool-result",
|
|
3591
|
-
toolCallId,
|
|
3592
|
-
toolName,
|
|
3593
|
-
output: {
|
|
3594
|
-
type: "error-json",
|
|
3595
|
-
value: { error: result.error.message },
|
|
3596
|
-
},
|
|
3597
|
-
isError: true,
|
|
3598
|
-
},
|
|
3599
|
-
],
|
|
3600
|
-
};
|
|
3601
|
-
// Use getToolErrorCode for consistent formatting with processStreamChunk
|
|
3602
|
-
const errorCode = getToolErrorCode(result.error);
|
|
3603
|
-
void sendUserMessage({
|
|
3604
|
-
role: "assistant",
|
|
3605
|
-
content: "",
|
|
3606
|
-
type: "tool-error",
|
|
3607
|
-
toolCallId,
|
|
3608
|
-
toolName,
|
|
3609
|
-
error: getToolErrorMessage(result.error.cause ?? result.error),
|
|
3610
|
-
errorCode,
|
|
3611
|
-
});
|
|
3612
|
-
}
|
|
3613
|
-
}
|
|
3614
|
-
catch (error) {
|
|
3615
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
3616
|
-
// Provider-facing context expects tool failures to be encoded as
|
|
3617
|
-
// errored tool results so they still satisfy the preceding tool call.
|
|
3618
|
-
toolResultMessage = {
|
|
3619
|
-
role: "tool",
|
|
3620
|
-
content: [
|
|
3621
|
-
{
|
|
3622
|
-
type: "tool-result",
|
|
3623
|
-
toolCallId,
|
|
3624
|
-
toolName,
|
|
3625
|
-
output: {
|
|
3626
|
-
type: "error-json",
|
|
3627
|
-
value: { error: errorMessage },
|
|
3628
|
-
},
|
|
3629
|
-
isError: true,
|
|
3630
|
-
},
|
|
3631
|
-
],
|
|
3632
|
-
};
|
|
3633
|
-
// Use getToolErrorCode for consistent formatting with processStreamChunk
|
|
3634
|
-
const errorCode = getToolErrorCode(error);
|
|
3635
|
-
void sendUserMessage({
|
|
3636
|
-
role: "assistant",
|
|
3637
|
-
content: "",
|
|
3638
|
-
type: "tool-error",
|
|
3639
|
-
toolCallId,
|
|
3640
|
-
toolName,
|
|
3641
|
-
error: errorMessage,
|
|
3642
|
-
errorCode,
|
|
3643
|
-
});
|
|
3644
|
-
}
|
|
4135
|
+
outcome = await this.executeApprovedTool(toolName, rawInput, toolCallId, abortController?.signal);
|
|
3645
4136
|
}
|
|
3646
4137
|
else if (request.kind === "delivery_error") {
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
content: [
|
|
3650
|
-
{
|
|
3651
|
-
type: TOOL_RESULT_CONTENT_TYPE,
|
|
3652
|
-
toolCallId,
|
|
3653
|
-
toolName,
|
|
3654
|
-
output: {
|
|
3655
|
-
type: ERROR_JSON_OUTPUT_TYPE,
|
|
3656
|
-
value: { error: request.error.message },
|
|
3657
|
-
},
|
|
3658
|
-
isError: true,
|
|
3659
|
-
},
|
|
3660
|
-
],
|
|
4138
|
+
outcome = {
|
|
4139
|
+
toolResultMessage: toolErrorResult(toolCallId, toolName, request.error.message),
|
|
3661
4140
|
};
|
|
3662
4141
|
}
|
|
3663
4142
|
else {
|
|
3664
|
-
|
|
4143
|
+
// A clarification reaches Clark as the user's next message, where the
|
|
4144
|
+
// prompt safety checks see it. Repeating it here would leak it past them.
|
|
4145
|
+
const denyReason = isClarification
|
|
4146
|
+
? CLARIFICATION_DENY_REASON
|
|
4147
|
+
: request.denyMessage || "User denied permission for this tool call";
|
|
3665
4148
|
if (toolName === "testApi") {
|
|
3666
4149
|
const apiName = rawInput?.apiName;
|
|
3667
4150
|
if (apiName) {
|
|
@@ -3675,56 +4158,51 @@ export class AiService extends TracedEventEmitter {
|
|
|
3675
4158
|
});
|
|
3676
4159
|
}
|
|
3677
4160
|
}
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
content: [
|
|
3681
|
-
{
|
|
3682
|
-
type: "tool-result",
|
|
3683
|
-
toolCallId,
|
|
3684
|
-
toolName,
|
|
3685
|
-
output: {
|
|
3686
|
-
type: "error-json",
|
|
3687
|
-
value: { error: `Permission denied: ${denyReason}` },
|
|
3688
|
-
},
|
|
3689
|
-
isError: true,
|
|
3690
|
-
},
|
|
3691
|
-
],
|
|
4161
|
+
outcome = {
|
|
4162
|
+
toolResultMessage: toolErrorResult(toolCallId, toolName, `Permission denied: ${denyReason}`),
|
|
3692
4163
|
};
|
|
3693
4164
|
logger.info(`[ai-service] Tool ${toolName} permission denied, error injected into context`);
|
|
3694
4165
|
}
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
4166
|
+
// The tool or its denial has already taken effect, so the outcome is
|
|
4167
|
+
// recorded even when a cancel or a newer prompt superseded this response.
|
|
4168
|
+
// Otherwise Clark cannot know the tool ran and may run it again. Only a
|
|
4169
|
+
// chat reset discards it: the conversation it belonged to is gone.
|
|
4170
|
+
if (this.chatSessionStore.getChatSessionId() !== chatSessionId) {
|
|
4171
|
+
recordToolPermissionLateOutcome("discarded");
|
|
4172
|
+
logger.warn("[ai-service] Discarded a tool permission outcome from a reset chat", { toolCallId, toolName });
|
|
4173
|
+
return;
|
|
4174
|
+
}
|
|
4175
|
+
const currentBeforeRecording = isCurrentResponse();
|
|
4176
|
+
try {
|
|
4177
|
+
await this.recordToolPermissionOutcome(contextId, llmConfig, assistantMessage, outcome, sendUserMessage);
|
|
4178
|
+
}
|
|
4179
|
+
catch (error) {
|
|
4180
|
+
if (currentBeforeRecording) {
|
|
4181
|
+
throw error;
|
|
4182
|
+
}
|
|
4183
|
+
recordToolPermissionLateOutcome("discarded");
|
|
4184
|
+
logger.warn("[ai-service] Could not record a superseded tool permission outcome", { toolCallId, toolName, ...getErrorMeta(error) });
|
|
4185
|
+
return;
|
|
4186
|
+
}
|
|
4187
|
+
if (!isCurrentResponse()) {
|
|
4188
|
+
recordToolPermissionLateOutcome("recorded");
|
|
4189
|
+
logger.info("[ai-service] Recorded a superseded tool permission outcome without continuing", { toolCallId, toolName });
|
|
4190
|
+
return;
|
|
4191
|
+
}
|
|
3715
4192
|
if (hasPendingEscalations()) {
|
|
3716
4193
|
logger.info(`[ai-service] Found pending escalation(s) after tool execution, flushing...`);
|
|
3717
4194
|
const escalationSent = await flushPendingEscalations({
|
|
3718
4195
|
clark: this.clark,
|
|
3719
4196
|
services: { chatSessionStore: this.chatSessionStore },
|
|
3720
4197
|
logger,
|
|
3721
|
-
force: true,
|
|
3722
|
-
skipStateTransition: true,
|
|
4198
|
+
force: true,
|
|
4199
|
+
skipStateTransition: true,
|
|
3723
4200
|
});
|
|
4201
|
+
if (!isCurrentResponse()) {
|
|
4202
|
+
return;
|
|
4203
|
+
}
|
|
3724
4204
|
if (escalationSent) {
|
|
3725
|
-
logger.info(`[ai-service] Sent escalation multi_choice to user
|
|
3726
|
-
// Stay in AwaitingUser state - don't transition to AgentPlanning
|
|
3727
|
-
// The user needs to respond to the escalation
|
|
4205
|
+
logger.info(`[ai-service] Sent escalation multi_choice to user`);
|
|
3728
4206
|
return;
|
|
3729
4207
|
}
|
|
3730
4208
|
}
|
|
@@ -3734,7 +4212,6 @@ export class AiService extends TracedEventEmitter {
|
|
|
3734
4212
|
if (!this.clark.context.llmConfig) {
|
|
3735
4213
|
throw new Error("Cannot continue after tool permission: llmConfig is missing from context");
|
|
3736
4214
|
}
|
|
3737
|
-
const isClarification = isToolPermissionClarification(approved, request.kind === "response" ? request.denyMessage : undefined);
|
|
3738
4215
|
logger.info("[ai-service] tool_permission_response continuation", {
|
|
3739
4216
|
approved,
|
|
3740
4217
|
clarification: isClarification,
|
|
@@ -3743,21 +4220,6 @@ export class AiService extends TracedEventEmitter {
|
|
|
3743
4220
|
trace
|
|
3744
4221
|
.getActiveSpan()
|
|
3745
4222
|
?.setAttribute("clark.tool_permission.clarification", isClarification);
|
|
3746
|
-
const promptOutcomeDedupeKey = randomUUID();
|
|
3747
|
-
this.clark.updateContext((context) => ({
|
|
3748
|
-
...context,
|
|
3749
|
-
promptOutcomeDedupeKey,
|
|
3750
|
-
}));
|
|
3751
|
-
// The continuation is a genuine new turn on a fresh key (the handoff
|
|
3752
|
-
// already recorded an outcome on the original). Pair it with an attempt
|
|
3753
|
-
// so attempts_total and outcomes_total stay aligned (ENG-5477 review).
|
|
3754
|
-
// Tag the entrypoint so dashboards can separate ~server-driven volume
|
|
3755
|
-
// from user aiGenerate (ENG-5477 scout review).
|
|
3756
|
-
recordPromptAttempt({
|
|
3757
|
-
coreModel: this.clark.context.llmConfig?.coreModel,
|
|
3758
|
-
entrypoint: PROMPT_ATTEMPT_ENTRYPOINT.tool_permission_continuation,
|
|
3759
|
-
outcomeDedupeKey: promptOutcomeDedupeKey,
|
|
3760
|
-
});
|
|
3761
4223
|
const continueRequest = {
|
|
3762
4224
|
prompt: approved || request.kind === "delivery_error"
|
|
3763
4225
|
? ``
|
|
@@ -3772,7 +4234,7 @@ export class AiService extends TracedEventEmitter {
|
|
|
3772
4234
|
llmConfig: this.clark.context.llmConfig,
|
|
3773
4235
|
mode: this.clark.context.currentMode,
|
|
3774
4236
|
browserContext: this.clark.context.browserContext,
|
|
3775
|
-
requestId: promptOutcomeDedupeKey,
|
|
4237
|
+
requestId: this.clark.context.promptOutcomeDedupeKey,
|
|
3776
4238
|
responseMetadata: {
|
|
3777
4239
|
type: "tool_permission_response",
|
|
3778
4240
|
responseToMessageId: request.messageId,
|
|
@@ -3786,11 +4248,101 @@ export class AiService extends TracedEventEmitter {
|
|
|
3786
4248
|
...(isClarification ? { clarification: true } : {}),
|
|
3787
4249
|
},
|
|
3788
4250
|
};
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
4251
|
+
return continueRequest;
|
|
4252
|
+
}
|
|
4253
|
+
/**
|
|
4254
|
+
* Run a user-approved tool with the turn's abort signal, so a cancel stops
|
|
4255
|
+
* it instead of leaving it running unobserved.
|
|
4256
|
+
*/
|
|
4257
|
+
async executeApprovedTool(toolName, rawInput, toolCallId, abortSignal) {
|
|
4258
|
+
const logger = this.getLogger();
|
|
4259
|
+
const accessControlContext = this.clark.getAccessControl(this.config.features);
|
|
4260
|
+
logger.info(`[ai-service] Executing approved tool: ${toolName}`);
|
|
4261
|
+
logger.info(`[ai-service] Access control context: mode=${accessControlContext.mode}`);
|
|
4262
|
+
try {
|
|
4263
|
+
const result = await this.toolRegistry.execute(toolName, rawInput, accessControlContext, { abortSignal, messages: [], toolCallId });
|
|
4264
|
+
if (result.type === "success") {
|
|
4265
|
+
return {
|
|
4266
|
+
chatMessage: {
|
|
4267
|
+
role: "assistant",
|
|
4268
|
+
content: "",
|
|
4269
|
+
type: "tool-result",
|
|
4270
|
+
toolCallId,
|
|
4271
|
+
toolName,
|
|
4272
|
+
// Use getToolOutput for consistent formatting with processStreamChunk
|
|
4273
|
+
output: getToolOutput(toolName, result.data),
|
|
4274
|
+
},
|
|
4275
|
+
toolResultMessage: {
|
|
4276
|
+
role: TOOL_MESSAGE_ROLE,
|
|
4277
|
+
content: [
|
|
4278
|
+
{
|
|
4279
|
+
type: TOOL_RESULT_CONTENT_TYPE,
|
|
4280
|
+
toolCallId,
|
|
4281
|
+
toolName,
|
|
4282
|
+
output: { type: "json", value: toJsonValue(result.data) },
|
|
4283
|
+
},
|
|
4284
|
+
],
|
|
4285
|
+
},
|
|
4286
|
+
};
|
|
4287
|
+
}
|
|
4288
|
+
logger.warn(`[ai-service] Tool ${toolName} execution failed: ${result.error.message}`);
|
|
4289
|
+
return {
|
|
4290
|
+
chatMessage: {
|
|
4291
|
+
role: "assistant",
|
|
4292
|
+
content: "",
|
|
4293
|
+
type: "tool-error",
|
|
4294
|
+
toolCallId,
|
|
4295
|
+
toolName,
|
|
4296
|
+
error: getToolErrorMessage(result.error.cause ?? result.error),
|
|
4297
|
+
// Use getToolErrorCode for consistent formatting with processStreamChunk
|
|
4298
|
+
errorCode: getToolErrorCode(result.error),
|
|
4299
|
+
},
|
|
4300
|
+
toolResultMessage: toolErrorResult(toolCallId, toolName, result.error.message),
|
|
4301
|
+
};
|
|
4302
|
+
}
|
|
4303
|
+
catch (error) {
|
|
4304
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
4305
|
+
return {
|
|
4306
|
+
chatMessage: {
|
|
4307
|
+
role: "assistant",
|
|
4308
|
+
content: "",
|
|
4309
|
+
type: "tool-error",
|
|
4310
|
+
toolCallId,
|
|
4311
|
+
toolName,
|
|
4312
|
+
error: errorMessage,
|
|
4313
|
+
errorCode: getToolErrorCode(error),
|
|
4314
|
+
},
|
|
4315
|
+
toolResultMessage: toolErrorResult(toolCallId, toolName, errorMessage),
|
|
4316
|
+
};
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
async recordToolPermissionOutcome(contextId, llmConfig, toolCall, outcome, sendUserMessage) {
|
|
4320
|
+
if (outcome.chatMessage) {
|
|
4321
|
+
void sendUserMessage(outcome.chatMessage);
|
|
4322
|
+
}
|
|
4323
|
+
const context = await this.toolPermissionOutcomeContext(contextId, llmConfig);
|
|
4324
|
+
await context.addResponses([toolCall, outcome.toolResultMessage], TOOL_PERMISSION_PLACEHOLDER_USAGE);
|
|
4325
|
+
}
|
|
4326
|
+
/**
|
|
4327
|
+
* The context a tool permission outcome is written to, retried a bounded
|
|
4328
|
+
* number of times so a transient store failure does not drop the record of a
|
|
4329
|
+
* tool that already ran. Only acquisition is retried: addResponses ingests
|
|
4330
|
+
* each message before persisting it, so repeating it could duplicate the
|
|
4331
|
+
* tool call.
|
|
4332
|
+
*/
|
|
4333
|
+
async toolPermissionOutcomeContext(contextId, llmConfig) {
|
|
4334
|
+
for (let attempt = 1;; attempt += 1) {
|
|
4335
|
+
try {
|
|
4336
|
+
const contextManagement = this.buildContextManagementForCurrentClark(llmConfig);
|
|
4337
|
+
return await this.contextManagerV2.getOrCreateContext(contextId, llmConfig?.contextOptionsV2, undefined, contextManagement);
|
|
4338
|
+
}
|
|
4339
|
+
catch (error) {
|
|
4340
|
+
if (attempt >= TOOL_PERMISSION_CONTEXT_ATTEMPTS) {
|
|
4341
|
+
throw error;
|
|
4342
|
+
}
|
|
4343
|
+
await new Promise((resolve) => setTimeout(resolve, TOOL_PERMISSION_CONTEXT_RETRY_MS * attempt));
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
3794
4346
|
}
|
|
3795
4347
|
/**
|
|
3796
4348
|
* Builds a continuation prompt when a tool permission is denied.
|
|
@@ -3849,6 +4401,15 @@ export class AiService extends TracedEventEmitter {
|
|
|
3849
4401
|
lastGenerationState: this.clark.context.lastGenerationState,
|
|
3850
4402
|
});
|
|
3851
4403
|
}
|
|
4404
|
+
getChatStateSnapshot() {
|
|
4405
|
+
const generationState = this.resolveGenerationStateForUi();
|
|
4406
|
+
return {
|
|
4407
|
+
generationState,
|
|
4408
|
+
activeInteractiveMessage: generationState?.state === AiGenerationState.AWAITING_USER_INPUT
|
|
4409
|
+
? this.clark.context.activeInteractiveMessage?.payload
|
|
4410
|
+
: undefined,
|
|
4411
|
+
};
|
|
4412
|
+
}
|
|
3852
4413
|
/**
|
|
3853
4414
|
* A gateway peer asked for a turn. Clark's pushes and tool callbacks for it
|
|
3854
4415
|
* are routed to that peer for as long as it is attached, without giving it
|
|
@@ -3995,6 +4556,103 @@ export class AiService extends TracedEventEmitter {
|
|
|
3995
4556
|
// push retries on a later bind.
|
|
3996
4557
|
void this.chatSessionStore.reconcileAbandonedToolCalls();
|
|
3997
4558
|
this.tryAutoDispatchMigrationTurnIfReady();
|
|
4559
|
+
void this.pushRestoredContextUsage();
|
|
4560
|
+
}
|
|
4561
|
+
/** Occupancy-ring flags only. Do not pass into getOrCreateContext. */
|
|
4562
|
+
editorContextOptionsV2;
|
|
4563
|
+
applyEditorContextOptions(contextOptionsV2) {
|
|
4564
|
+
// Assigned even when empty. The editor is authoritative for the ring, so
|
|
4565
|
+
// keeping the previous value here would outlive an override it cleared.
|
|
4566
|
+
this.editorContextOptionsV2 = contextOptionsV2;
|
|
4567
|
+
if (this.isBusy()) {
|
|
4568
|
+
return;
|
|
4569
|
+
}
|
|
4570
|
+
void this.pushRestoredContextUsageFromDisk();
|
|
4571
|
+
}
|
|
4572
|
+
/**
|
|
4573
|
+
* Fill the chat occupancy ring from the last completed step, or from the
|
|
4574
|
+
* restored conversation on disk when this process has not streamed yet.
|
|
4575
|
+
*/
|
|
4576
|
+
async pushRestoredContextUsage() {
|
|
4577
|
+
if (this.clark.replayLastContextUsage?.()) {
|
|
4578
|
+
return;
|
|
4579
|
+
}
|
|
4580
|
+
if (this.isBusy()) {
|
|
4581
|
+
return;
|
|
4582
|
+
}
|
|
4583
|
+
await this.pushRestoredContextUsageFromDisk();
|
|
4584
|
+
}
|
|
4585
|
+
restoredContextUsageRead;
|
|
4586
|
+
restoredContextUsageRerun = false;
|
|
4587
|
+
/**
|
|
4588
|
+
* One read at a time: parallel getOrCreateContext calls on a cold cache each
|
|
4589
|
+
* build their own ContextV2. A caller that arrives mid-read reruns it once.
|
|
4590
|
+
*/
|
|
4591
|
+
pushRestoredContextUsageFromDisk() {
|
|
4592
|
+
if (this.restoredContextUsageRead) {
|
|
4593
|
+
this.restoredContextUsageRerun = true;
|
|
4594
|
+
return this.restoredContextUsageRead;
|
|
4595
|
+
}
|
|
4596
|
+
this.restoredContextUsageRead = (async () => {
|
|
4597
|
+
try {
|
|
4598
|
+
do {
|
|
4599
|
+
this.restoredContextUsageRerun = false;
|
|
4600
|
+
await this.readRestoredContextUsageFromDisk();
|
|
4601
|
+
} while (this.restoredContextUsageRerun);
|
|
4602
|
+
}
|
|
4603
|
+
finally {
|
|
4604
|
+
this.restoredContextUsageRead = undefined;
|
|
4605
|
+
}
|
|
4606
|
+
})();
|
|
4607
|
+
return this.restoredContextUsageRead;
|
|
4608
|
+
}
|
|
4609
|
+
async readRestoredContextUsageFromDisk() {
|
|
4610
|
+
const generation = this.clark.getContextUsageGeneration?.();
|
|
4611
|
+
// No JWT yet means no user; updateClarkJwt retries once it arrives.
|
|
4612
|
+
const contextId = this.getContextId();
|
|
4613
|
+
if (!contextId) {
|
|
4614
|
+
return;
|
|
4615
|
+
}
|
|
4616
|
+
try {
|
|
4617
|
+
const displayOptions = this.editorContextOptionsV2 ??
|
|
4618
|
+
this.clark.context.llmConfig?.contextOptionsV2;
|
|
4619
|
+
if (typeof displayOptions?.contextLimit !== "number") {
|
|
4620
|
+
return;
|
|
4621
|
+
}
|
|
4622
|
+
const context = await this.contextManagerV2.getOrCreateContext(contextId);
|
|
4623
|
+
const lastKnownInputTokens = context.getLastKnownInputTokens();
|
|
4624
|
+
const messages = await context.getMessages();
|
|
4625
|
+
const storedBreakdown = context.getLastContextUsage()?.breakdown;
|
|
4626
|
+
if (lastKnownInputTokens <= 0 &&
|
|
4627
|
+
messages.length === 0 &&
|
|
4628
|
+
!storedBreakdown) {
|
|
4629
|
+
return;
|
|
4630
|
+
}
|
|
4631
|
+
const snapshot = buildRestoredContextUsageSnapshot({
|
|
4632
|
+
contextOptions: displayOptions,
|
|
4633
|
+
lastKnownInputTokens,
|
|
4634
|
+
messages,
|
|
4635
|
+
storedBreakdown,
|
|
4636
|
+
});
|
|
4637
|
+
if (snapshot.contextUsed <= 0) {
|
|
4638
|
+
return;
|
|
4639
|
+
}
|
|
4640
|
+
// A New Chat, restore or live step during the reads above makes this stale.
|
|
4641
|
+
if (this.isBusy() ||
|
|
4642
|
+
this.clark.getContextUsageGeneration?.() !== generation) {
|
|
4643
|
+
return;
|
|
4644
|
+
}
|
|
4645
|
+
const { breakdown } = snapshot;
|
|
4646
|
+
if (breakdown.systemPrompt > 0 ||
|
|
4647
|
+
breakdown.tools > 0 ||
|
|
4648
|
+
breakdown.summarizedConversation > 0) {
|
|
4649
|
+
this.clark.rememberContextUsage(snapshot);
|
|
4650
|
+
}
|
|
4651
|
+
catchBackgroundPeerDisconnect(this.stablePeer.call.aiPushContextUsed(snapshot), "restored context usage push");
|
|
4652
|
+
}
|
|
4653
|
+
catch (error) {
|
|
4654
|
+
this.getLogger().warn("[ai-service] Failed to restore context usage before the next step", getErrorMeta(error));
|
|
4655
|
+
}
|
|
3998
4656
|
}
|
|
3999
4657
|
/**
|
|
4000
4658
|
* Per-session set of peer IDs that connected since their last task
|
|
@@ -4062,6 +4720,9 @@ export class AiService extends TracedEventEmitter {
|
|
|
4062
4720
|
logger: this.getLogger(),
|
|
4063
4721
|
}, callerUserId);
|
|
4064
4722
|
}
|
|
4723
|
+
if (this.stablePeer.peerId === peerId) {
|
|
4724
|
+
void this.pushRestoredContextUsage();
|
|
4725
|
+
}
|
|
4065
4726
|
}
|
|
4066
4727
|
/**
|
|
4067
4728
|
* @param replacement An editor socket that is still attached. Clark's RPCs
|
|
@@ -4928,6 +5589,13 @@ export class AiService extends TracedEventEmitter {
|
|
|
4928
5589
|
pendingToolPermissionRequest: undefined,
|
|
4929
5590
|
resumeAfterAuthFailure: undefined,
|
|
4930
5591
|
});
|
|
5592
|
+
this.clark.clearLastContextUsage?.();
|
|
5593
|
+
if (!this.isBusy()) {
|
|
5594
|
+
catchBackgroundPeerDisconnect(sendUserGenerationStateChannel(this.clark)({
|
|
5595
|
+
state: AiGenerationState.IDLE,
|
|
5596
|
+
chatSessionReset: true,
|
|
5597
|
+
}), "new chat generation state push");
|
|
5598
|
+
}
|
|
4931
5599
|
}
|
|
4932
5600
|
reportAbandonTurn(action) {
|
|
4933
5601
|
if (!isAiAbandonTurnState(this.clark.state))
|
|
@@ -4963,6 +5631,7 @@ export class AiService extends TracedEventEmitter {
|
|
|
4963
5631
|
/** Clears local LLM context so migration-era memory does not survive abort. */
|
|
4964
5632
|
async clearMigrationAbortLocalContext() {
|
|
4965
5633
|
this.clark.updateContext({ resumeAfterAuthFailure: undefined });
|
|
5634
|
+
this.clark.clearLastContextUsage?.();
|
|
4966
5635
|
const contextId = getContextId(this.clark, this.config);
|
|
4967
5636
|
await this.contextManagerV2.clearAllContexts();
|
|
4968
5637
|
if (contextId) {
|
|
@@ -4976,11 +5645,13 @@ export class AiService extends TracedEventEmitter {
|
|
|
4976
5645
|
*/
|
|
4977
5646
|
async uploadEmptyContextToBucketeer(commitId) {
|
|
4978
5647
|
const jwt = this.getInferenceJwt();
|
|
4979
|
-
|
|
4980
|
-
if (!jwt || !commitId || !branchName) {
|
|
5648
|
+
if (!jwt || !commitId) {
|
|
4981
5649
|
return;
|
|
4982
5650
|
}
|
|
4983
5651
|
try {
|
|
5652
|
+
const branchName = await this._syncContextProvider?.getBranchName();
|
|
5653
|
+
if (!branchName)
|
|
5654
|
+
return;
|
|
4984
5655
|
await uploadContextToBucketeer({
|
|
4985
5656
|
applicationId: this.config.applicationId,
|
|
4986
5657
|
commitId,
|
|
@@ -5086,6 +5757,15 @@ export class AiService extends TracedEventEmitter {
|
|
|
5086
5757
|
};
|
|
5087
5758
|
}
|
|
5088
5759
|
async handleRestoreToCommit(sinceTimestamp, commitId, applicationId, branchName) {
|
|
5760
|
+
try {
|
|
5761
|
+
await this.restoreToCommit(sinceTimestamp, commitId, applicationId, branchName);
|
|
5762
|
+
}
|
|
5763
|
+
finally {
|
|
5764
|
+
// The editor empties its ring when a restore starts; refill it before replying.
|
|
5765
|
+
await this.pushRestoredContextUsage();
|
|
5766
|
+
}
|
|
5767
|
+
}
|
|
5768
|
+
async restoreToCommit(sinceTimestamp, commitId, applicationId, branchName) {
|
|
5089
5769
|
// Note: clark.context.jwt should be set by the socket handler (socket-manager.ts) before
|
|
5090
5770
|
// this method is called. The socket handler updates it from peerAuthorization if missing.
|
|
5091
5771
|
const jwt = this.clark?.context?.jwt;
|
|
@@ -5162,6 +5842,7 @@ export class AiService extends TracedEventEmitter {
|
|
|
5162
5842
|
this.getLogger().info(`[handleRestoreToCommit] No context restored (${outcome}) for commitId=${commitId}; leaving chat intact`, { applicationId, branchName });
|
|
5163
5843
|
return;
|
|
5164
5844
|
}
|
|
5845
|
+
this.clark.clearLastContextUsage?.();
|
|
5165
5846
|
// 2. Context is rolled back — truncate the server chat to match. This is a
|
|
5166
5847
|
// cross-service RPC and is NOT transactional with the context rollback
|
|
5167
5848
|
// above, so a failure here leaves the context rolled back while the chat
|
|
@@ -5209,6 +5890,10 @@ export class AiService extends TracedEventEmitter {
|
|
|
5209
5890
|
// timestamps and regenerating from YAML on-demand when YAML is newer.
|
|
5210
5891
|
}
|
|
5211
5892
|
isBusy() {
|
|
5893
|
+
return this.generationStarting || this.isClarkMidTurn();
|
|
5894
|
+
}
|
|
5895
|
+
/** Whether Clark's own state machine is running a turn, ignoring a turn still being started. */
|
|
5896
|
+
isClarkMidTurn() {
|
|
5212
5897
|
return (this.clark.state !== ClarkStateNames.Idle &&
|
|
5213
5898
|
this.clark.state !== ClarkStateNames.AwaitingUser &&
|
|
5214
5899
|
this.clark.state !== ClarkStateNames.Dead);
|
|
@@ -5216,6 +5901,17 @@ export class AiService extends TracedEventEmitter {
|
|
|
5216
5901
|
isAwaitingUser() {
|
|
5217
5902
|
return this.clark.state === ClarkStateNames.AwaitingUser;
|
|
5218
5903
|
}
|
|
5904
|
+
autoDispatchBlockReason() {
|
|
5905
|
+
if (this.generationStarting ||
|
|
5906
|
+
(this.clark.state !== ClarkStateNames.Idle &&
|
|
5907
|
+
this.clark.state !== ClarkStateNames.AwaitingUser))
|
|
5908
|
+
return "busy";
|
|
5909
|
+
if (this.clark.context.pendingToolPermissionRequest)
|
|
5910
|
+
return "tool_permission";
|
|
5911
|
+
if (this.isAwaitingInteractiveResponse())
|
|
5912
|
+
return "interactive";
|
|
5913
|
+
return undefined;
|
|
5914
|
+
}
|
|
5219
5915
|
/**
|
|
5220
5916
|
* True when finalize (or similar) must wait for the user to resolve an
|
|
5221
5917
|
* interactive prompt or tool-permission widget. Unlike {@link isAwaitingUser},
|
|
@@ -5280,13 +5976,21 @@ export class AiService extends TracedEventEmitter {
|
|
|
5280
5976
|
/**
|
|
5281
5977
|
* Resolve generation waiters when Clark reaches a terminal state.
|
|
5282
5978
|
*/
|
|
5283
|
-
triggerGenerationComplete() {
|
|
5979
|
+
triggerGenerationComplete(generationId) {
|
|
5980
|
+
if (generationId !== this.currentGenerationId) {
|
|
5981
|
+
if (generationId !== undefined) {
|
|
5982
|
+
this.emit("generationSettled", generationId);
|
|
5983
|
+
}
|
|
5984
|
+
return;
|
|
5985
|
+
}
|
|
5284
5986
|
// The FSM parked, so any still-open generation span is over. Runs that
|
|
5285
5987
|
// completed or failed already ended it via the generation events; this
|
|
5286
5988
|
// ends it on the paths that emit neither (user/agent cancel,
|
|
5287
5989
|
// runtime-updated-without-edits, planning errors that park in Idle).
|
|
5288
5990
|
this.activeGenerationTrace?.finish();
|
|
5289
|
-
|
|
5991
|
+
if (generationId !== undefined) {
|
|
5992
|
+
this.emit("generationSettled", generationId);
|
|
5993
|
+
}
|
|
5290
5994
|
const waiters = Array.from(this.generationCompleteWaiters);
|
|
5291
5995
|
this.generationCompleteWaiters.clear();
|
|
5292
5996
|
for (const resolve of waiters) {
|
|
@@ -5307,10 +6011,18 @@ export class AiService extends TracedEventEmitter {
|
|
|
5307
6011
|
this.fileSystemInterface.setSourceTracker(sourceTracker);
|
|
5308
6012
|
}
|
|
5309
6013
|
setFileSystemManager(fileSystemManager) {
|
|
6014
|
+
this.fileSystemManager = fileSystemManager;
|
|
5310
6015
|
this.fileSystemInterface.setFileSystemManager(fileSystemManager);
|
|
5311
6016
|
// Also set the template renderer for YAML→TS sync
|
|
5312
6017
|
fileSystemManager.setTemplateRenderer(this.templateRenderer);
|
|
5313
6018
|
}
|
|
6019
|
+
async refreshFileModel() {
|
|
6020
|
+
if (!this.fileSystemManager) {
|
|
6021
|
+
throw new Error("File model is not available");
|
|
6022
|
+
}
|
|
6023
|
+
await this.fileSystemManager.initialize(this.config.appRootDirPath);
|
|
6024
|
+
this.setSourceTracker(this.fileSystemManager.getSourceTracker());
|
|
6025
|
+
}
|
|
5314
6026
|
setViteServer(server) {
|
|
5315
6027
|
this.viteServer = server;
|
|
5316
6028
|
}
|