@agent-native/core 0.149.1 → 0.149.2

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.
Files changed (115) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +2 -0
  3. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +150 -46
  4. package/corpus/templates/analytics/app/global.css +1 -1
  5. package/corpus/templates/analytics/app/i18n-data.ts +37 -0
  6. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +1 -1
  7. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +32 -0
  8. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +2 -0
  9. package/corpus/templates/analytics/changelog/2026-08-10-dashboard-tables-can-be-copied-and-sorted-by-multiple-column.md +6 -0
  10. package/corpus/templates/analytics/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  11. package/corpus/templates/assets/app/global.css +1 -1
  12. package/corpus/templates/assets/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  13. package/corpus/templates/brain/app/global.css +1 -1
  14. package/corpus/templates/brain/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  15. package/corpus/templates/chat/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  16. package/corpus/templates/clips/changelog/2026-08-10-clips-desktop-sign-in-now-starts-with-magic-links-and-google.md +6 -0
  17. package/corpus/templates/clips/chrome-extension/src/overlay.css +1 -0
  18. package/corpus/templates/clips/desktop/src/app.tsx +274 -137
  19. package/corpus/templates/clips/desktop/src/styles.css +35 -0
  20. package/corpus/templates/content/app/global.css +1 -0
  21. package/corpus/templates/crm/app/global.css +1 -1
  22. package/corpus/templates/crm/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  23. package/corpus/templates/dispatch/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  24. package/corpus/templates/factory/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  25. package/corpus/templates/forms/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  26. package/corpus/templates/plan/app/global.css +1 -0
  27. package/corpus/templates/plan/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  28. package/corpus/templates/plan/server/ui-plan-html.ts +4 -1
  29. package/corpus/templates/slides/AGENTS.md +11 -10
  30. package/corpus/templates/slides/actions/add-deck.ts +2 -0
  31. package/corpus/templates/slides/actions/add-slide.ts +15 -1
  32. package/corpus/templates/slides/actions/create-deck.ts +18 -6
  33. package/corpus/templates/slides/actions/get-deck.ts +29 -1
  34. package/corpus/templates/slides/actions/patch-deck.ts +122 -9
  35. package/corpus/templates/slides/actions/save-deck.ts +24 -1
  36. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +134 -618
  37. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +69 -60
  38. package/corpus/templates/slides/app/components/editor/GeneratingSlidePreview.tsx +1 -1
  39. package/corpus/templates/slides/app/components/editor/GoogleDriveConnectionCta.tsx +13 -1
  40. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +56 -174
  41. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +239 -116
  42. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +3 -3
  43. package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +1 -1
  44. package/corpus/templates/slides/app/components/layout/Layout.tsx +34 -35
  45. package/corpus/templates/slides/app/components/layout/layout-route-policy.ts +2 -5
  46. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +45 -40
  47. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +63 -16
  48. package/corpus/templates/slides/app/hooks/use-generating-slide-preview.ts +25 -3
  49. package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
  50. package/corpus/templates/slides/app/lib/create-deck-generation.ts +5 -4
  51. package/corpus/templates/slides/app/lib/recent-references.ts +23 -1
  52. package/corpus/templates/slides/app/lib/slide-animation-elements.ts +41 -3
  53. package/corpus/templates/slides/app/pages/DeckEditor.tsx +4 -27
  54. package/corpus/templates/slides/app/pages/Index.tsx +240 -89
  55. package/corpus/templates/slides/changelog/2026-08-10-ai-editing-avatar-badge.md +6 -0
  56. package/corpus/templates/slides/changelog/2026-08-10-generated-decks-keep-a-clear-human-readable-title.md +6 -0
  57. package/corpus/templates/slides/changelog/2026-08-10-new-deck-creation-now-shows-progress-opens-the-editor-direct.md +6 -0
  58. package/corpus/templates/slides/changelog/2026-08-10-presentation-reveals-now-follow-valid-targets.md +6 -0
  59. package/corpus/templates/slides/changelog/2026-08-10-slides-editing-controls-are-consolidated-into-one-focused-me.md +6 -0
  60. package/corpus/templates/slides/changelog/2026-08-10-slides-now-verify-and-restyle-every-imported-slide-in-one-pa.md +6 -0
  61. package/corpus/templates/slides/changelog/2026-08-10-slides-only-suggests-a-google-connection-for-relevant-reques.md +6 -0
  62. package/corpus/templates/slides/changelog/2026-08-10-the-google-slides-import-option-is-now-labeled-slides.md +6 -0
  63. package/corpus/templates/slides/server/lib/chat-attachments.ts +1 -1
  64. package/corpus/templates/slides/server/plugins/agent-chat.ts +5 -3
  65. package/corpus/templates/slides/shared/api.ts +2 -1
  66. package/corpus/templates/slides/shared/deck-title.ts +135 -0
  67. package/corpus/templates/tasks/app/components/tasks/TaskListRow.tsx +4 -1
  68. package/dist/agent/engine/ai-sdk-engine.js +1 -1
  69. package/dist/agent/production-agent.d.ts +21 -1
  70. package/dist/agent/production-agent.js +129 -10
  71. package/dist/agent/tool-approval-store.d.ts +18 -0
  72. package/dist/agent/tool-approval-store.js +122 -0
  73. package/dist/agent/types.d.ts +1 -1
  74. package/dist/cli/code-agent-executor.js +11 -2
  75. package/dist/client/AgentPanel.js +31 -6
  76. package/dist/client/AssistantChat.d.ts +3 -2
  77. package/dist/client/AssistantChat.js +7 -20
  78. package/dist/client/agent-chat/index.d.ts +1 -1
  79. package/dist/client/agent-chat-adapter.js +11 -0
  80. package/dist/client/agent-chat.d.ts +1 -1
  81. package/dist/client/chat/markdown-renderer.js +14 -2
  82. package/dist/client/chat/message-components.d.ts +6 -2
  83. package/dist/client/chat/message-components.js +25 -47
  84. package/dist/client/chat/tool-call-display.d.ts +3 -5
  85. package/dist/client/chat/tool-call-display.js +7 -212
  86. package/dist/client/chat-first/apps-rail.js +3 -3
  87. package/dist/client/chat-first/browser-pane.js +4 -1
  88. package/dist/client/chat-first/chat-history.d.ts +3 -1
  89. package/dist/client/chat-first/chat-history.js +2 -2
  90. package/dist/client/chat-first/surface-tabs.d.ts +1 -1
  91. package/dist/client/chat-first/surface-tabs.js +52 -41
  92. package/dist/client/chat-first/types.d.ts +3 -0
  93. package/dist/client/chat-first.d.ts +10 -0
  94. package/dist/client/chat-first.js +37 -2
  95. package/dist/client/components/ui/message-scroller.js +4 -1
  96. package/dist/client/i18n.js +3 -1
  97. package/dist/client/resources/McpConnectionSuggestion.js +9 -1
  98. package/dist/client/sse-event-processor.js +10 -0
  99. package/dist/client/use-chat-models.js +156 -80
  100. package/dist/collab/struct-routes.d.ts +1 -1
  101. package/dist/integrations/webhook-handler.js +1 -1
  102. package/dist/observability/routes.d.ts +1 -1
  103. package/dist/provider-api/actions/custom-provider-registration.d.ts +1 -1
  104. package/dist/resources/handlers.d.ts +1 -1
  105. package/dist/secrets/routes.d.ts +9 -9
  106. package/dist/server/auth.js +262 -31
  107. package/dist/server/onboarding-html.js +56 -9
  108. package/dist/shared/reasoning-effort.d.ts +8 -8
  109. package/dist/shared/reasoning-effort.js +10 -10
  110. package/dist/shared/streaming-text-smoothing.js +41 -11
  111. package/dist/styles/agent-native.css +19 -32
  112. package/dist/templates/chat/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
  113. package/docs/content/template-chat.mdx +1 -1
  114. package/package.json +2 -2
  115. package/src/templates/chat/changelog/2026-08-10-full-page-chat-now-uses-the-available-width-up-to-1000px-for.md +6 -0
@@ -137,7 +137,7 @@ function normalizeString(value: unknown, fallback: string): string {
137
137
  }
138
138
 
139
139
  function normalizeElementPath(value: unknown): number[] | undefined {
140
- if (!Array.isArray(value)) return undefined;
140
+ if (!Array.isArray(value) || value.length === 0) return undefined;
141
141
  const path = value.filter(
142
142
  (part): part is number =>
143
143
  typeof part === "number" &&
@@ -155,6 +155,7 @@ function normalizeSlideAnimation(
155
155
  if (!isRecord(value)) return null;
156
156
 
157
157
  const elementPath = normalizeElementPath(value.elementPath);
158
+ if (value.elementPath !== undefined && !elementPath) return null;
158
159
  const rawElementIndex = value.elementIndex;
159
160
  const hasElementIndex =
160
161
  typeof rawElementIndex === "number" &&
@@ -0,0 +1,135 @@
1
+ const GENERATED_TITLE_PLACEHOLDERS = new Set([
2
+ "deck",
3
+ "date",
4
+ "image slide title",
5
+ "presentation title",
6
+ "section",
7
+ "section title",
8
+ "slide title",
9
+ "untitled",
10
+ "untitled deck",
11
+ "your name",
12
+ ]);
13
+
14
+ /**
15
+ * Generated deck ids should never become user-facing titles. Keep this
16
+ * deliberately narrow so normal titles with spaces and punctuation remain
17
+ * valid, while catching opaque mixed-case tokens such as H3sVsnns-TEVUOpz9w.
18
+ */
19
+ const OPAQUE_DECK_TITLE_PATTERN =
20
+ /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z0-9_-]{12,64}$/;
21
+
22
+ export const DEFAULT_DECK_TITLE = "Untitled Deck";
23
+
24
+ export function isOpaqueDeckTitle(value: unknown): value is string {
25
+ return (
26
+ typeof value === "string" && OPAQUE_DECK_TITLE_PATTERN.test(value.trim())
27
+ );
28
+ }
29
+
30
+ export function isGeneratedDeckTitle(value: unknown): value is string {
31
+ if (typeof value !== "string") return false;
32
+ const title = value.trim();
33
+ return (
34
+ GENERATED_TITLE_PLACEHOLDERS.has(title.toLowerCase()) ||
35
+ isOpaqueDeckTitle(title)
36
+ );
37
+ }
38
+
39
+ function decodeHtmlEntities(value: string): string {
40
+ const decodeCodePoint = (raw: string, radix: number): string => {
41
+ const codePoint = Number.parseInt(raw, radix);
42
+ return Number.isNaN(codePoint) || codePoint > 0x10ffff
43
+ ? ""
44
+ : String.fromCodePoint(codePoint);
45
+ };
46
+
47
+ return value
48
+ .replace(/ /gi, " ")
49
+ .replace(/&/gi, "&")
50
+ .replace(/&lt;/gi, "<")
51
+ .replace(/&gt;/gi, ">")
52
+ .replace(/&quot;/gi, '"')
53
+ .replace(/(?:&#39;|&apos;)/gi, "'")
54
+ .replace(/&#x([0-9a-f]+);?/gi, (_, hex: string) => decodeCodePoint(hex, 16))
55
+ .replace(/&#(\d+);?/g, (_, digits: string) => decodeCodePoint(digits, 10));
56
+ }
57
+
58
+ function plainText(value: string): string {
59
+ return decodeHtmlEntities(
60
+ value.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]+>/g, " "),
61
+ )
62
+ .replace(/\s+/g, " ")
63
+ .trim();
64
+ }
65
+
66
+ function usableCandidate(value: string): string | null {
67
+ const candidate = plainText(value).replace(/^[•●▪‣\-\s]+/, "");
68
+ if (!candidate || candidate.length > 140) return null;
69
+ if (GENERATED_TITLE_PLACEHOLDERS.has(candidate.toLowerCase())) return null;
70
+ return candidate;
71
+ }
72
+
73
+ /**
74
+ * Recover a deck title from the largest title-like text in its first slide.
75
+ * This is intentionally HTML-string based because the action runs on the
76
+ * server without a browser DOM.
77
+ */
78
+ export function deriveDeckTitleFromSlideContent(
79
+ content: unknown,
80
+ ): string | null {
81
+ if (typeof content !== "string" || !content.trim()) return null;
82
+
83
+ const candidates: Array<{ score: number; text: string }> = [];
84
+ const headingPattern = /<(h[1-6])\b[^>]*>([\s\S]*?)<\/\1>/gi;
85
+ for (const match of content.matchAll(headingPattern)) {
86
+ const text = usableCandidate(match[2] ?? "");
87
+ if (text) {
88
+ const level = Number.parseInt(match[1].slice(1), 10);
89
+ candidates.push({ score: 1000 - level * 10, text });
90
+ }
91
+ }
92
+
93
+ const styledTextPattern =
94
+ /<([a-z][\w:-]*)\b[^>]*\bstyle\s*=\s*(["'])([\s\S]*?)\2[^>]*>([\s\S]*?)<\/\1>/gi;
95
+ for (const match of content.matchAll(styledTextPattern)) {
96
+ const fontSize = Number.parseFloat(
97
+ match[3]?.match(/(?:^|;)\s*font-size\s*:\s*([\d.]+)px/i)?.[1] ?? "0",
98
+ );
99
+ if (fontSize < 28) continue;
100
+ const text = usableCandidate(match[4] ?? "");
101
+ if (text) candidates.push({ score: fontSize, text });
102
+ }
103
+
104
+ return candidates.sort((a, b) => b.score - a.score)[0]?.text ?? null;
105
+ }
106
+
107
+ /**
108
+ * Return a human-readable replacement only when the requested title is a
109
+ * generated placeholder or opaque id. A meaningful existing title wins when
110
+ * a stale full-payload save tries to replace it with a generated value.
111
+ */
112
+ export function repairGeneratedDeckTitle(
113
+ requestedTitle: unknown,
114
+ firstSlideContent: unknown,
115
+ existingTitle?: unknown,
116
+ ): string | null {
117
+ if (!isGeneratedDeckTitle(requestedTitle)) return null;
118
+
119
+ return (
120
+ deriveDeckTitleFromSlideContent(firstSlideContent) ??
121
+ (typeof existingTitle === "string" &&
122
+ existingTitle.trim() &&
123
+ !isGeneratedDeckTitle(existingTitle)
124
+ ? existingTitle
125
+ : null)
126
+ );
127
+ }
128
+
129
+ export function assertHumanReadableDeckTitle(title: string): void {
130
+ if (isOpaqueDeckTitle(title)) {
131
+ throw new Error(
132
+ "Deck title must be a concise, human-readable title; generated ids are not valid titles.",
133
+ );
134
+ }
135
+ }
@@ -89,7 +89,10 @@ export function TaskListRow({
89
89
  onAnimationEnd={completion.handleRowAnimationEnd}
90
90
  dataAttributes={{ "data-task-id": item.id }}
91
91
  className={cn(
92
- "will-change-[opacity,transform]",
92
+ // Scoped to the phases that actually animate. Applied unconditionally
93
+ // it promoted a layer for every row in the list and never retired any
94
+ // of them, for an animation that only runs while a row completes.
95
+ completionPhase !== "idle" && "will-change-[opacity,transform]",
93
96
  displayDone && completionPhase === "idle" && !selected && "opacity-60",
94
97
  completionPhase === "completing" &&
95
98
  hideAfterComplete &&
@@ -138,7 +138,7 @@ function googleThinkingBudget(effort) {
138
138
  return -1;
139
139
  }
140
140
  /**
141
- * Map a reasoning effort level to Gemini 3.x thinkingLevel string.
141
+ * Map an effort level to Gemini 3.x thinkingLevel string.
142
142
  * Gemini 3 models (gemini-3.*) reject thinkingBudget and require thinkingLevel
143
143
  * with values 'low' | 'medium' | 'high'. Gemini 3.0 only supports 'low'/'high';
144
144
  * Gemini 3.1+ adds 'medium'. We always emit 'medium' for medium effort since it
@@ -266,7 +266,7 @@ export interface ProductionAgentOptions {
266
266
  model?: string;
267
267
  /** App/template id used for org-scoped per-app model defaults. */
268
268
  appId?: string;
269
- /** Default reasoning effort for requests that do not supply an override. */
269
+ /** Default effort for requests that do not supply an override. */
270
270
  reasoningEffort?: ReasoningEffort;
271
271
  /** Provider-specific options passed through to the engine */
272
272
  providerOptions?: EngineMessage extends never ? never : any;
@@ -632,8 +632,16 @@ export interface ExecuteAgentToolCallOptions {
632
632
  threadId?: string;
633
633
  turnId?: string;
634
634
  approvedToolCalls?: string[];
635
+ onApprovalRequired?: (binding: AgentApprovalBinding) => Promise<void>;
636
+ consumeApproval?: (binding: AgentApprovalBinding) => Promise<boolean>;
635
637
  send?: (event: AgentChatEvent) => void;
636
638
  }
639
+ export interface AgentApprovalBinding {
640
+ toolName: string;
641
+ input: unknown;
642
+ callId: string;
643
+ approvalKey: string;
644
+ }
637
645
  /**
638
646
  * Execute one externally-selected tool through the exact same guarded runtime
639
647
  * as a normal model-selected tool call. Realtime voice and other duplex
@@ -658,6 +666,11 @@ export declare function buildFirstRequestPayloadDetail(input: {
658
666
  availableToolCount: number;
659
667
  }): string;
660
668
  export declare function toolCallCacheKey(toolName: string, input: unknown): string;
669
+ export declare function findApprovedStructuredToolCall(history: AgentChatStructuredMessage[] | undefined, approvedToolCalls: readonly string[] | undefined): {
670
+ name: string;
671
+ input: unknown;
672
+ callId: string;
673
+ } | undefined;
661
674
  export declare function repeatedSourceSweepGuardMessage(opts: {
662
675
  toolName: string;
663
676
  priorCalls: number;
@@ -769,6 +782,13 @@ export declare function runAgentLoop(opts: {
769
782
  * `approval_required`. See `AgentChatRequest.approvedToolCalls`.
770
783
  */
771
784
  approvedToolCalls?: string[];
785
+ /**
786
+ * Server-side approval persistence hooks. The HTTP transport supplies these
787
+ * so client-supplied history is never the authorization source; direct loop
788
+ * callers may omit them when they own the surrounding approval boundary.
789
+ */
790
+ onApprovalRequired?: (binding: AgentApprovalBinding) => Promise<void>;
791
+ consumeApproval?: (binding: AgentApprovalBinding) => Promise<boolean>;
772
792
  /**
773
793
  * In-loop processor seam (see `processors.ts`). Each processor can observe
774
794
  * streamed chunks, observe model responses around tool execution, and
@@ -39,6 +39,7 @@ import { ProcessorChain, TripWire, toolCallsFromContent, } from "./processors.js
39
39
  import { startRun, subscribeToRun, getActiveRunForThread, getActiveRunForThreadAsync, getRun, abortRun, abortRunDurably, abortTurnDurably, tryClaimRunSlot, isHostedRuntime, resolveRunSoftTimeoutMs, resolveRunToolTimeoutCeilingMs, } from "./run-manager.js";
40
40
  import { writeLedgerEntry, readLedgerEntry, clearLedgerForThread, insertRun, insertRunEvent, isTurnAborted, markRunAborted, updateRunHeartbeat, updateRunStatusIfRunning, setRunError, setRunTerminalReason, claimBackgroundRun, readBackgroundRunClaim, recordRunDiagnostic, countRunsForTurn, RUN_DIAG_STAGE, UNCLAIMED_BACKGROUND_RUN_GRACE_MS, } from "./run-store.js";
41
41
  import { buildCurrentTimeUserContext } from "./runtime-context.js";
42
+ import { consumeAgentToolApproval, createAgentToolApproval, } from "./tool-approval-store.js";
42
43
  import { findCompletedJournalEntry, } from "./tool-call-journal.js";
43
44
  import { redactSensitiveFields, sanitizeToolErrorText, sanitizeToolErrorValue, } from "./tool-error-redaction.js";
44
45
  import { describeToolResultImages, extractAgentImagesFromActionResult, } from "./tool-result-images.js";
@@ -688,7 +689,7 @@ const VISIBLE_RETRY_THRESHOLD_MS = 10_000;
688
689
  */
689
690
  const FOREGROUND_FIRST_MODEL_EVENT_TIMEOUT_MS = 25_000;
690
691
  // Raised from 1 -> 2 now that each retry actually adapts (raises the token
691
- // ceiling and steps reasoning effort down a tier) instead of re-issuing the
692
+ // ceiling and steps effort down a tier) instead of re-issuing the
692
693
  // exact same doomed request twice.
693
694
  const EMPTY_FINAL_RESPONSE_RETRY_LIMIT = 2;
694
695
  const MAIN_CHAT_INTERNAL_CONTINUATION_LIMIT = 6;
@@ -2120,6 +2121,8 @@ export async function executeAgentToolCall(options) {
2120
2121
  threadId: options.threadId,
2121
2122
  turnId: options.turnId,
2122
2123
  approvedToolCalls: options.approvedToolCalls,
2124
+ onApprovalRequired: options.onApprovalRequired,
2125
+ consumeApproval: options.consumeApproval,
2123
2126
  });
2124
2127
  }
2125
2128
  catch (error) {
@@ -2307,6 +2310,51 @@ function stableStringify(value) {
2307
2310
  export function toolCallCacheKey(toolName, input) {
2308
2311
  return `${toolName}:${stableStringify(normalizeToolCallInputForHistory(input))}`;
2309
2312
  }
2313
+ export function findApprovedStructuredToolCall(history, approvedToolCalls) {
2314
+ if (!Array.isArray(history) || !approvedToolCalls?.length)
2315
+ return undefined;
2316
+ const approved = new Set(approvedToolCalls);
2317
+ const calls = new Map();
2318
+ let match;
2319
+ for (const message of history) {
2320
+ if (message.role === "assistant") {
2321
+ for (const part of message.content) {
2322
+ if (part.type !== "tool-call")
2323
+ continue;
2324
+ const callId = typeof part.id === "string"
2325
+ ? part.id
2326
+ : typeof part.toolCallId === "string"
2327
+ ? part.toolCallId
2328
+ : "";
2329
+ const name = typeof part.name === "string"
2330
+ ? part.name
2331
+ : typeof part.toolName === "string"
2332
+ ? part.toolName
2333
+ : "";
2334
+ if (!callId || !name)
2335
+ continue;
2336
+ calls.set(callId, { name, input: part.input ?? part.args ?? {} });
2337
+ }
2338
+ continue;
2339
+ }
2340
+ for (const part of message.content) {
2341
+ if (part.type !== "tool-result")
2342
+ continue;
2343
+ const call = calls.get(part.toolCallId);
2344
+ if (!call || !approved.has(toolCallCacheKey(call.name, call.input))) {
2345
+ continue;
2346
+ }
2347
+ const result = part.content.toLowerCase();
2348
+ if (!result.includes("did not execute") &&
2349
+ !result.includes("awaiting human approval") &&
2350
+ !result.includes("waiting for your approval")) {
2351
+ continue;
2352
+ }
2353
+ match = { ...call, callId: part.toolCallId };
2354
+ }
2355
+ }
2356
+ return match;
2357
+ }
2310
2358
  const INTERRUPTED_TOOL_LEDGER_POLL_MS = process.env.NODE_ENV === "test" ? 0 : 2_000;
2311
2359
  async function waitForInterruptedToolLedgerEntry(opts) {
2312
2360
  const pollMs = INTERRUPTED_TOOL_LEDGER_POLL_MS;
@@ -3965,7 +4013,16 @@ export async function runAgentLoop(opts) {
3965
4013
  const approvalKey = toolCallCacheKey(toolCall.name, toolCall.input);
3966
4014
  // Consume a grant on its first exact match. A second identical call in
3967
4015
  // the same continuation must request its own human approval.
3968
- const wasApproved = approvedToolCallKeys.delete(approvalKey);
4016
+ const approvalBinding = {
4017
+ toolName: toolCall.name,
4018
+ input: toolCall.input,
4019
+ callId: toolCall.id,
4020
+ approvalKey,
4021
+ };
4022
+ const requestedApproval = approvedToolCallKeys.delete(approvalKey);
4023
+ const wasApproved = requestedApproval && opts.consumeApproval
4024
+ ? await opts.consumeApproval(approvalBinding)
4025
+ : requestedApproval;
3969
4026
  if (actionEntry.needsApproval && !wasApproved) {
3970
4027
  let mustApprove = false;
3971
4028
  try {
@@ -3989,6 +4046,7 @@ export async function runAgentLoop(opts) {
3989
4046
  mustApprove = true;
3990
4047
  }
3991
4048
  if (mustApprove) {
4049
+ await opts.onApprovalRequired?.(approvalBinding);
3992
4050
  send({
3993
4051
  type: "tool_start",
3994
4052
  id: toolCall.id,
@@ -6433,6 +6491,12 @@ export function createProductionAgentHandler(options) {
6433
6491
  ...historyMessages,
6434
6492
  { role: "user", content: userContent },
6435
6493
  ];
6494
+ const requestedApprovedToolCalls = Array.isArray(body.approvedToolCalls) && body.approvedToolCalls.length > 0
6495
+ ? body.approvedToolCalls
6496
+ .filter((key) => typeof key === "string")
6497
+ .slice(0, 200)
6498
+ : undefined;
6499
+ const exactApprovedToolCall = findApprovedStructuredToolCall(structuredHistory, requestedApprovedToolCalls);
6436
6500
  const firstRequestPayloadDetail = buildFirstRequestPayloadDetail({
6437
6501
  isFirstRequest: history.length === 0,
6438
6502
  systemPrompt: requestSystemPrompt,
@@ -6486,6 +6550,38 @@ export function createProductionAgentHandler(options) {
6486
6550
  : typeof requestTurnId === "string" && requestTurnId.trim()
6487
6551
  ? requestTurnId.trim()
6488
6552
  : runId;
6553
+ const approvalStoreBinding = (binding) => {
6554
+ if (!ownerEmail) {
6555
+ throw new Error("Cannot create or consume an approval without an authenticated owner");
6556
+ }
6557
+ return {
6558
+ ownerEmail,
6559
+ orgId: getRequestOrgId() ?? null,
6560
+ threadId: effectiveThreadId,
6561
+ turnId: effectiveTurnId,
6562
+ toolName: binding.toolName,
6563
+ callId: binding.callId,
6564
+ approvalKey: binding.approvalKey,
6565
+ };
6566
+ };
6567
+ const approvalHooks = {
6568
+ onApprovalRequired: async (binding) => {
6569
+ await createAgentToolApproval(approvalStoreBinding(binding));
6570
+ },
6571
+ consumeApproval: async (binding) => {
6572
+ if (!ownerEmail)
6573
+ return false;
6574
+ return consumeAgentToolApproval(approvalStoreBinding(binding));
6575
+ },
6576
+ };
6577
+ const exactApprovedToolEntry = exactApprovedToolCall
6578
+ ? requestActions[exactApprovedToolCall.name]
6579
+ : undefined;
6580
+ const approvedToolCallsForExecution = exactApprovedToolCall && exactApprovedToolEntry?.needsApproval
6581
+ ? [
6582
+ toolCallCacheKey(exactApprovedToolCall.name, exactApprovedToolCall.input),
6583
+ ]
6584
+ : undefined;
6489
6585
  if (isBackgroundWorker &&
6490
6586
  (await isTurnAborted(effectiveThreadId, effectiveTurnId))) {
6491
6587
  await markRunAborted(runId, "user").catch(() => { });
@@ -7236,15 +7332,13 @@ export function createProductionAgentHandler(options) {
7236
7332
  ? { threadId: effectiveThreadId, turnId: effectiveTurnId }
7237
7333
  : {}),
7238
7334
  // Human-in-the-loop approval grants for this turn (sanitized — the
7239
- // request is untrusted; accept only a bounded list of string keys).
7240
- ...(Array.isArray(body.approvedToolCalls) &&
7241
- body.approvedToolCalls.length
7242
- ? {
7243
- approvedToolCalls: body.approvedToolCalls
7244
- .filter((k) => typeof k === "string")
7245
- .slice(0, 200),
7246
- }
7335
+ // request is untrusted; only the exact structured call is passed to
7336
+ // the loop, where the durable grant is consumed atomically.
7337
+ ...(approvedToolCallsForExecution
7338
+ ? { approvedToolCalls: approvedToolCallsForExecution }
7247
7339
  : {}),
7340
+ onApprovalRequired: approvalHooks.onApprovalRequired,
7341
+ consumeApproval: approvalHooks.consumeApproval,
7248
7342
  // A worker PROVEN to be running inside the real 15-min Netlify
7249
7343
  // `-background` function (`runsInBackgroundFunction`) has minutes of
7250
7344
  // budget left on THIS invocation. Let it catch a recoverable
@@ -7269,6 +7363,31 @@ export function createProductionAgentHandler(options) {
7269
7363
  send({ type: "activity", label: "Contacting model" });
7270
7364
  let instrumented = false;
7271
7365
  try {
7366
+ if (exactApprovedToolCall &&
7367
+ approvedToolCallsForExecution &&
7368
+ requestActions[exactApprovedToolCall.name]) {
7369
+ send({
7370
+ type: "activity",
7371
+ label: `Running approved ${exactApprovedToolCall.name} action`,
7372
+ tool: exactApprovedToolCall.name,
7373
+ });
7374
+ await executeAgentToolCall({
7375
+ actions: requestActions,
7376
+ name: exactApprovedToolCall.name,
7377
+ input: exactApprovedToolCall.input,
7378
+ callId: exactApprovedToolCall.callId,
7379
+ signal: agentLoopOpts.signal,
7380
+ ownerEmail,
7381
+ orgId: getRequestOrgId() ?? null,
7382
+ threadId: effectiveThreadId,
7383
+ turnId: effectiveTurnId,
7384
+ approvedToolCalls: approvedToolCallsForExecution,
7385
+ onApprovalRequired: approvalHooks.onApprovalRequired,
7386
+ consumeApproval: approvalHooks.consumeApproval,
7387
+ send,
7388
+ });
7389
+ return;
7390
+ }
7272
7391
  try {
7273
7392
  const { getObservabilityConfig, instrumentAgentLoop } = await import("../observability/traces.js");
7274
7393
  const obsConfig = await getObservabilityConfig();
@@ -0,0 +1,18 @@
1
+ export interface AgentToolApprovalBinding {
2
+ ownerEmail: string;
3
+ orgId?: string | null;
4
+ threadId?: string | null;
5
+ turnId?: string | null;
6
+ toolName: string;
7
+ callId: string;
8
+ approvalKey: string;
9
+ expiresAt?: number;
10
+ }
11
+ export declare function hashAgentToolApprovalKey(approvalKey: string): string;
12
+ export declare function createAgentToolApproval(binding: AgentToolApprovalBinding): Promise<void>;
13
+ /**
14
+ * Atomically consume the server-created approval for one exact tool call.
15
+ * Client history and approval keys are only lookup input; the pending row is
16
+ * the authorization boundary and cannot be manufactured by the client.
17
+ */
18
+ export declare function consumeAgentToolApproval(binding: AgentToolApprovalBinding): Promise<boolean>;
@@ -0,0 +1,122 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { getDbExec, intType, isPostgres, retryOnDdlRace, } from "../db/client.js";
3
+ import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
4
+ const APPROVAL_TTL_MS = 15 * 60_000;
5
+ const APPROVAL_CLEANUP_AGE_MS = 24 * 60 * 60_000;
6
+ let initPromise;
7
+ async function ensureAgentToolApprovalTable() {
8
+ if (!initPromise) {
9
+ initPromise = (async () => {
10
+ const createSql = `CREATE TABLE IF NOT EXISTS agent_tool_approvals (
11
+ id TEXT PRIMARY KEY,
12
+ owner_email TEXT NOT NULL,
13
+ org_id TEXT,
14
+ thread_id TEXT,
15
+ turn_id TEXT,
16
+ tool_name TEXT NOT NULL,
17
+ call_id TEXT NOT NULL,
18
+ approval_key_hash TEXT NOT NULL,
19
+ status TEXT NOT NULL,
20
+ expires_at ${intType()} NOT NULL,
21
+ consumed_at ${intType()},
22
+ created_at ${intType()} NOT NULL,
23
+ updated_at ${intType()} NOT NULL
24
+ )`;
25
+ const indexSql = `CREATE INDEX IF NOT EXISTS idx_agent_tool_approvals_binding
26
+ ON agent_tool_approvals(owner_email, org_id, thread_id, tool_name, call_id, status)`;
27
+ if (isPostgres()) {
28
+ await ensureTableExists("agent_tool_approvals", createSql);
29
+ await ensureIndexExists("idx_agent_tool_approvals_binding", indexSql);
30
+ return;
31
+ }
32
+ const client = getDbExec();
33
+ await retryOnDdlRace(() => client.execute(createSql));
34
+ await retryOnDdlRace(() => client.execute(indexSql));
35
+ })().catch((error) => {
36
+ initPromise = undefined;
37
+ throw error;
38
+ });
39
+ }
40
+ return initPromise;
41
+ }
42
+ export function hashAgentToolApprovalKey(approvalKey) {
43
+ return createHash("sha256").update(approvalKey).digest("hex");
44
+ }
45
+ export async function createAgentToolApproval(binding) {
46
+ await ensureAgentToolApprovalTable();
47
+ const now = Date.now();
48
+ const expiresAt = binding.expiresAt ?? now + APPROVAL_TTL_MS;
49
+ await getDbExec().execute({
50
+ sql: `INSERT INTO agent_tool_approvals
51
+ (id, owner_email, org_id, thread_id, turn_id, tool_name, call_id,
52
+ approval_key_hash, status, expires_at, consumed_at, created_at, updated_at)
53
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL, ?, ?)`,
54
+ args: [
55
+ `agent-tool-approval-${randomUUID()}`,
56
+ binding.ownerEmail,
57
+ binding.orgId ?? null,
58
+ binding.threadId ?? null,
59
+ binding.turnId ?? null,
60
+ binding.toolName,
61
+ binding.callId,
62
+ hashAgentToolApprovalKey(binding.approvalKey),
63
+ expiresAt,
64
+ now,
65
+ now,
66
+ ],
67
+ });
68
+ // Cleanup is hygiene only. Expired rows remain unusable because consume uses
69
+ // an atomic status and expiry predicate, so cleanup failures must not affect
70
+ // the approval that was just recorded.
71
+ try {
72
+ await getDbExec().execute({
73
+ sql: `DELETE FROM agent_tool_approvals
74
+ WHERE expires_at < ? AND status <> 'pending'`,
75
+ args: [now - APPROVAL_CLEANUP_AGE_MS],
76
+ });
77
+ }
78
+ catch (error) {
79
+ console.warn("[agent] Could not clean expired tool approval records", error);
80
+ }
81
+ }
82
+ /**
83
+ * Atomically consume the server-created approval for one exact tool call.
84
+ * Client history and approval keys are only lookup input; the pending row is
85
+ * the authorization boundary and cannot be manufactured by the client.
86
+ */
87
+ export async function consumeAgentToolApproval(binding) {
88
+ await ensureAgentToolApprovalTable();
89
+ const now = Date.now();
90
+ const result = await getDbExec().execute({
91
+ sql: `UPDATE agent_tool_approvals
92
+ SET status = 'consumed', consumed_at = ?, updated_at = ?
93
+ WHERE id = (
94
+ SELECT id FROM agent_tool_approvals
95
+ WHERE owner_email = ?
96
+ AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)
97
+ AND ((thread_id IS NULL AND CAST(? AS TEXT) IS NULL) OR thread_id = ?)
98
+ AND tool_name = ?
99
+ AND call_id = ?
100
+ AND approval_key_hash = ?
101
+ AND status = 'pending'
102
+ AND expires_at > ?
103
+ ORDER BY created_at DESC
104
+ LIMIT 1
105
+ )
106
+ AND status = 'pending'`,
107
+ args: [
108
+ now,
109
+ now,
110
+ binding.ownerEmail,
111
+ binding.orgId ?? null,
112
+ binding.orgId ?? null,
113
+ binding.threadId ?? null,
114
+ binding.threadId ?? null,
115
+ binding.toolName,
116
+ binding.callId,
117
+ hashAgentToolApprovalKey(binding.approvalKey),
118
+ now,
119
+ ],
120
+ });
121
+ return result.rowsAffected === 1;
122
+ }
@@ -189,7 +189,7 @@ export interface AgentChatRequest {
189
189
  model?: string;
190
190
  /** Per-request engine override (sent alongside model for cross-provider switches). */
191
191
  engine?: string;
192
- /** Per-request reasoning effort override (ephemeral, from the composer picker). */
192
+ /** Per-request effort override (ephemeral, from the composer picker). */
193
193
  effort?: ReasoningEffort;
194
194
  /** Usage-tracking label for this call (e.g. "chat", "summarize"). Default: "chat". */
195
195
  usageLabel?: string;
@@ -471,7 +471,14 @@ async function executeClaudeCliRun(options) {
471
471
  });
472
472
  try {
473
473
  const result = await runClaudeCodeParticipant({
474
- role: options.permissionMode === "full-auto" ? "driver" : "watchdog",
474
+ // Claude's driver uses acceptEdits, which is the closest available
475
+ // mapping for auto-edit and full-auto. Keep ask-before-edit on the
476
+ // read-only watchdog path because this non-interactive runner has no
477
+ // approval channel to honor an edit prompt safely.
478
+ role: options.permissionMode === "auto-edit" ||
479
+ options.permissionMode === "full-auto"
480
+ ? "driver"
481
+ : "watchdog",
475
482
  prompt: buildClaudeCliPrompt(options.run, options.prompt),
476
483
  cwd,
477
484
  model,
@@ -604,7 +611,9 @@ async function executeClaudeCliRun(options) {
604
611
  }
605
612
  function buildClaudeCliPrompt(run, prompt) {
606
613
  const permissionMode = run.permissionMode ?? "full-auto";
607
- const mode = permissionMode === "full-auto" ? "Auto" : "Plan";
614
+ const mode = permissionMode === "auto-edit" || permissionMode === "full-auto"
615
+ ? "Auto"
616
+ : "Plan";
608
617
  const modeInstruction = mode === "Plan"
609
618
  ? "Inspect and explain only. Do not edit files or run mutating commands."
610
619
  : "Edit and verify as needed. Do not create, switch, reset, rebase, or stash git branches.";