@agent-native/core 0.204.0 → 0.204.1-nightly-20261008034100

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 (106) hide show
  1. package/dist/action.js +5 -6
  2. package/dist/agent/auto-continue.d.ts +35 -0
  3. package/dist/agent/auto-continue.js +38 -0
  4. package/dist/agent/connection-required-note.d.ts +52 -0
  5. package/dist/agent/connection-required-note.js +187 -0
  6. package/dist/agent/jev-tool-prefetch.d.ts +5 -1
  7. package/dist/agent/jev-tool-prefetch.js +19 -6
  8. package/dist/agent/previous-turn-note.d.ts +9 -0
  9. package/dist/agent/previous-turn-note.js +48 -0
  10. package/dist/agent/production-agent.d.ts +17 -13
  11. package/dist/agent/production-agent.js +185 -43
  12. package/dist/agent/reaped-run-thread.d.ts +13 -0
  13. package/dist/agent/reaped-run-thread.js +45 -0
  14. package/dist/agent/run-store.d.ts +29 -5
  15. package/dist/agent/run-store.js +144 -30
  16. package/dist/agent/thread-data-builder.d.ts +16 -0
  17. package/dist/agent/thread-data-builder.js +30 -8
  18. package/dist/agent/thread-message-projection.js +5 -3
  19. package/dist/agent/tool-search.js +5 -1
  20. package/dist/agent/types.d.ts +3 -0
  21. package/dist/audit/config.d.ts +6 -0
  22. package/dist/audit/config.js +12 -1
  23. package/dist/audit/index.d.ts +1 -1
  24. package/dist/audit/index.js +1 -1
  25. package/dist/audit/store.js +39 -4
  26. package/dist/cli/skills.d.ts +1 -0
  27. package/dist/cli/skills.js +1 -1
  28. package/dist/client/chat/agentkit-agent-native.js +83 -7
  29. package/dist/client/chat/agentkit-protocol.js +140 -67
  30. package/dist/client/chat/runtime.js +5 -3
  31. package/dist/client/use-run-stuck-detection.d.ts +18 -1
  32. package/dist/client/use-run-stuck-detection.js +115 -75
  33. package/dist/db/request-telemetry.d.ts +7 -1
  34. package/dist/db/request-telemetry.js +20 -1
  35. package/dist/deploy/build.d.ts +25 -1
  36. package/dist/deploy/build.js +176 -3
  37. package/dist/deploy/workspace-deploy.js +8 -3
  38. package/dist/extensions/fetch-tool.js +50 -14
  39. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  40. package/dist/integrations/adapters/email.js +11 -3
  41. package/dist/integrations/adapters/telegram.js +4 -2
  42. package/dist/jobs/actions/automation-no-op.d.ts +12 -0
  43. package/dist/jobs/actions/automation-no-op.js +20 -0
  44. package/dist/jobs/actions/manage-recurring-job.d.ts +1 -1
  45. package/dist/jobs/background-automation-runner.d.ts +8 -1
  46. package/dist/jobs/background-automation-runner.js +102 -19
  47. package/dist/jobs/run-history.d.ts +1 -1
  48. package/dist/jobs/run-history.js +8 -4
  49. package/dist/jobs/scheduler.js +25 -12
  50. package/dist/localization/automation-outcome-messages.d.ts +22 -0
  51. package/dist/localization/automation-outcome-messages.js +90 -0
  52. package/dist/localization/default-messages.d.ts +2 -0
  53. package/dist/localization/default-messages.js +4 -2
  54. package/dist/notifications/routes.d.ts +1 -1
  55. package/dist/observability/metrics.d.ts +3 -0
  56. package/dist/observability/metrics.js +10 -0
  57. package/dist/observability/migrations.js +22 -0
  58. package/dist/observability/routes.d.ts +13 -8
  59. package/dist/observability/routes.js +88 -14
  60. package/dist/observability/sentiment.d.ts +5 -0
  61. package/dist/observability/sentiment.js +75 -25
  62. package/dist/observability/store.d.ts +28 -0
  63. package/dist/observability/store.js +58 -0
  64. package/dist/observability/traces.js +37 -5
  65. package/dist/org/index.d.ts +2 -2
  66. package/dist/progress/actions.js +1 -0
  67. package/dist/provider-api/actions/custom-provider-registration.d.ts +12 -12
  68. package/dist/provider-api/actions/provider-api.d.ts +7 -7
  69. package/dist/provider-api/actions/provider-api.js +6 -2
  70. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  71. package/dist/provider-api/index.d.ts +1 -0
  72. package/dist/provider-api/index.js +18 -0
  73. package/dist/provider-api/result-outcome.d.ts +1 -0
  74. package/dist/provider-api/result-outcome.js +20 -0
  75. package/dist/provider-api/staging.js +10 -9
  76. package/dist/server/agent-chat/prompt-resources.d.ts +8 -2
  77. package/dist/server/agent-chat/prompt-resources.js +30 -7
  78. package/dist/server/agent-chat/recurring-jobs-runtime.d.ts +19 -4
  79. package/dist/server/agent-chat/recurring-jobs-runtime.js +52 -16
  80. package/dist/server/agent-chat/sweep-trigger-auth.d.ts +18 -0
  81. package/dist/server/agent-chat/sweep-trigger-auth.js +36 -0
  82. package/dist/server/agent-chat-plugin.js +20 -13
  83. package/dist/server/agent-engine-default-model-route.d.ts +2 -2
  84. package/dist/server/auth.js +4 -4
  85. package/dist/server/framework-request-handler.js +19 -0
  86. package/dist/server/http-response-telemetry.js +4 -1
  87. package/dist/server/realtime-token.d.ts +1 -1
  88. package/dist/server/request-context.d.ts +6 -0
  89. package/dist/shared/context-status.d.ts +21 -0
  90. package/dist/shared/context-status.js +54 -0
  91. package/dist/templates/chat/app/components/chat/ChatRouteContent.tsx +8 -1
  92. package/dist/templates/workspace-core/.agents/skills/automations/SKILL.md +8 -1
  93. package/dist/templates/workspace-core/.agents/skills/recurring-jobs/SKILL.md +3 -1
  94. package/dist/triggers/actions/manage-automation.d.ts +7 -7
  95. package/dist/triggers/dispatcher.js +10 -7
  96. package/docs/content/audit-log.mdx +3 -3
  97. package/docs/content/cloudflare.mdx +27 -0
  98. package/docs/content/deployment-environment-variables.mdx +2 -0
  99. package/docs/content/durable-resume.mdx +3 -1
  100. package/docs/content/environment-variables.mdx +2 -0
  101. package/docs/content/recurring-jobs.mdx +29 -9
  102. package/docs/content/vercel.mdx +30 -0
  103. package/package.json +3 -3
  104. package/src/templates/chat/app/components/chat/ChatRouteContent.tsx +8 -1
  105. package/src/templates/workspace-core/.agents/skills/automations/SKILL.md +8 -1
  106. package/src/templates/workspace-core/.agents/skills/recurring-jobs/SKILL.md +3 -1
@@ -0,0 +1,22 @@
1
+ import { type BuiltinLocaleCode } from "./shared.js";
2
+ export declare const AUTOMATION_OUTCOME_MESSAGES: Record<BuiltinLocaleCode, {
3
+ noWork: string;
4
+ emptyDelivery: string;
5
+ emailNotSent: string;
6
+ noOpInstruction: string;
7
+ noOpReason: string;
8
+ }>;
9
+ export declare function automationOutcomeMessagesForLocale(locale: string): {
10
+ noWork: string;
11
+ emptyDelivery: string;
12
+ emailNotSent: string;
13
+ noOpInstruction: string;
14
+ noOpReason: string;
15
+ };
16
+ export declare function automationOutcomeMessagesForUser(userEmail: string | null | undefined): Promise<{
17
+ noWork: string;
18
+ emptyDelivery: string;
19
+ emailNotSent: string;
20
+ noOpInstruction: string;
21
+ noOpReason: string;
22
+ }>;
@@ -0,0 +1,90 @@
1
+ import { getUserSetting } from "../settings/user-settings.js";
2
+ import { DEFAULT_LOCALE, isLocaleCode, LOCALIZATION_SETTING_KEY, normalizeLocalizationPreference, } from "./shared.js";
3
+ export const AUTOMATION_OUTCOME_MESSAGES = {
4
+ "en-US": {
5
+ noOpInstruction: "If nothing needs doing, call automation-no-op with a short reason. Failed tools, sends, or missing credentials are errors, not no-ops.",
6
+ noOpReason: "A short reason why no action or report was needed (1–500 characters).",
7
+ emailNotSent: "The email was not sent by a delivery provider.",
8
+ noWork: "The automation ended without a confirmed action. Configure a delivery destination for read-only reports.",
9
+ emptyDelivery: "The automation produced no message for its configured delivery destination.",
10
+ },
11
+ "es-ES": {
12
+ noOpInstruction: "Si no hay nada que hacer, llama a automation-no-op con un motivo breve. Las herramientas o los envíos fallidos y las credenciales ausentes son errores, no casos sin trabajo.",
13
+ noOpReason: "Motivo breve por el que no se necesitaba ninguna acción ni informe (1–500 caracteres).",
14
+ emailNotSent: "El correo no fue enviado por un proveedor de entrega.",
15
+ noWork: "La automatización terminó sin una acción confirmada. Configura un destino de entrega para los informes de solo lectura.",
16
+ emptyDelivery: "La automatización no produjo ningún mensaje para su destino de entrega configurado.",
17
+ },
18
+ "fr-FR": {
19
+ noOpInstruction: "Si rien n’est à faire, appelez automation-no-op avec une courte raison. Les échecs d’outils ou d’envoi et les identifiants manquants sont des erreurs, pas une absence de travail.",
20
+ noOpReason: "Courte raison pour laquelle aucune action ni aucun rapport n’était nécessaire (1–500 caractères).",
21
+ emailNotSent: "L’e-mail n’a pas été envoyé par un fournisseur de messagerie.",
22
+ noWork: "L’automatisation s’est terminée sans action confirmée. Configurez une destination de livraison pour les rapports en lecture seule.",
23
+ emptyDelivery: "L’automatisation n’a produit aucun message pour sa destination de livraison configurée.",
24
+ },
25
+ "de-DE": {
26
+ noOpInstruction: "Wenn nichts zu tun ist, rufen Sie automation-no-op mit einer kurzen Begründung auf. Fehlgeschlagene Tools oder Zustellungen und fehlende Zugangsdaten sind Fehler, keine Fälle ohne Handlungsbedarf.",
27
+ noOpReason: "Kurze Begründung, warum keine Aktion oder kein Bericht nötig war (1–500 Zeichen).",
28
+ emailNotSent: "Die E-Mail wurde von keinem Zustellanbieter versendet.",
29
+ noWork: "Die Automatisierung endete ohne bestätigte Aktion. Konfigurieren Sie ein Zustellungsziel für schreibgeschützte Berichte.",
30
+ emptyDelivery: "Die Automatisierung hat keine Nachricht für ihr konfiguriertes Zustellungsziel erzeugt.",
31
+ },
32
+ "pt-BR": {
33
+ noOpInstruction: "Se não houver nada a fazer, chame automation-no-op com um motivo breve. Falhas de ferramentas ou envios e credenciais ausentes são erros, não ausência de trabalho.",
34
+ noOpReason: "Motivo breve pelo qual nenhuma ação ou relatório era necessário (1–500 caracteres).",
35
+ emailNotSent: "O e-mail não foi enviado por um provedor de entrega.",
36
+ noWork: "A automação terminou sem uma ação confirmada. Configure um destino de entrega para relatórios somente de leitura.",
37
+ emptyDelivery: "A automação não produziu nenhuma mensagem para seu destino de entrega configurado.",
38
+ },
39
+ "zh-CN": {
40
+ noOpInstruction: "如果没有需要执行的工作,请调用 automation-no-op 并提供简短原因。工具失败、发送失败或缺少凭据属于错误,不是无需操作。",
41
+ noOpReason: "简要说明为何无需执行操作或发送报告(1–500 个字符)。",
42
+ emailNotSent: "电子邮件未通过邮件服务提供商发送。",
43
+ noWork: "自动化结束时没有已确认的操作。请为只读报告配置发送目标。",
44
+ emptyDelivery: "自动化未生成要发送到已配置目标的消息。",
45
+ },
46
+ "zh-TW": {
47
+ noOpInstruction: "如果沒有需要執行的工作,請呼叫 automation-no-op 並提供簡短原因。工具失敗、傳送失敗或缺少憑證屬於錯誤,不是無需操作。",
48
+ noOpReason: "簡要說明為何無需執行操作或傳送報告(1–500 個字元)。",
49
+ emailNotSent: "電子郵件未透過郵件服務供應商傳送。",
50
+ noWork: "自動化結束時沒有已確認的操作。請為唯讀報告設定傳送目標。",
51
+ emptyDelivery: "自動化未產生要傳送到已設定目標的訊息。",
52
+ },
53
+ "ja-JP": {
54
+ noOpInstruction: "対応が不要な場合は、短い理由を添えて automation-no-op を呼び出してください。ツールや送信の失敗、認証情報の不足はエラーであり、対応不要には該当しません。",
55
+ noOpReason: "操作やレポートが不要だった短い理由(1~500文字)。",
56
+ emailNotSent: "メールは配信プロバイダーから送信されませんでした。",
57
+ noWork: "自動化は操作が確認されないまま終了しました。読み取り専用のレポートには配信先を設定してください。",
58
+ emptyDelivery: "自動化は設定された配信先へのメッセージを生成しませんでした。",
59
+ },
60
+ "ko-KR": {
61
+ noOpInstruction: "할 일이 없으면 짧은 이유와 함께 automation-no-op을 호출하세요. 도구 또는 전송 실패와 자격 증명 누락은 작업 없음이 아닌 오류입니다.",
62
+ noOpReason: "작업이나 보고서가 필요하지 않았던 짧은 이유(1–500자).",
63
+ emailNotSent: "이메일이 전송 제공업체를 통해 전송되지 않았습니다.",
64
+ noWork: "확인된 작업 없이 자동화가 종료되었습니다. 읽기 전용 보고서에는 전송 대상을 설정하세요.",
65
+ emptyDelivery: "자동화가 설정된 전송 대상에 보낼 메시지를 생성하지 않았습니다.",
66
+ },
67
+ "hi-IN": {
68
+ noOpInstruction: "यदि कुछ करने की आवश्यकता नहीं है, तो संक्षिप्त कारण के साथ automation-no-op कॉल करें। विफल टूल, विफल भेजने की कोशिश या अनुपलब्ध क्रेडेंशियल त्रुटियाँ हैं, काम न होने की स्थिति नहीं।",
69
+ noOpReason: "किसी कार्रवाई या रिपोर्ट की आवश्यकता न होने का संक्षिप्त कारण (1–500 अक्षर)।",
70
+ emailNotSent: "ईमेल किसी डिलीवरी प्रदाता द्वारा नहीं भेजा गया।",
71
+ noWork: "ऑटोमेशन किसी पुष्ट कार्रवाई के बिना समाप्त हो गया। केवल पढ़ने वाली रिपोर्ट के लिए डिलीवरी गंतव्य कॉन्फ़िगर करें।",
72
+ emptyDelivery: "ऑटोमेशन ने अपने कॉन्फ़िगर किए गए डिलीवरी गंतव्य के लिए कोई संदेश नहीं बनाया।",
73
+ },
74
+ "ar-SA": {
75
+ noOpInstruction: "إذا لم يكن هناك ما يجب فعله، استدعِ automation-no-op مع سبب موجز. فشل الأدوات أو الإرسال أو غياب بيانات الاعتماد أخطاء، وليس حالات لا تتطلب عملاً.",
76
+ noOpReason: "سبب موجز لعدم الحاجة إلى إجراء أو تقرير (1–500 حرف).",
77
+ emailNotSent: "لم تُرسل رسالة البريد الإلكتروني عبر مزوّد تسليم.",
78
+ noWork: "انتهت الأتمتة دون إجراء مؤكد. اضبط وجهة تسليم للتقارير المخصصة للقراءة فقط.",
79
+ emptyDelivery: "لم تنتج الأتمتة أي رسالة لوجهة التسليم المحددة.",
80
+ },
81
+ };
82
+ export function automationOutcomeMessagesForLocale(locale) {
83
+ return AUTOMATION_OUTCOME_MESSAGES[isLocaleCode(locale) ? locale : DEFAULT_LOCALE];
84
+ }
85
+ export async function automationOutcomeMessagesForUser(userEmail) {
86
+ const preference = normalizeLocalizationPreference(userEmail
87
+ ? await getUserSetting(userEmail, LOCALIZATION_SETTING_KEY)
88
+ : undefined);
89
+ return automationOutcomeMessagesForLocale(preference.locale);
90
+ }
@@ -1018,9 +1018,11 @@ declare const messages: {
1018
1018
  scheduleUnavailableLocalTitle: string;
1019
1019
  scheduleUnavailableDisabled: string;
1020
1020
  scheduleUnavailableNoScheduler: string;
1021
+ scheduleUnavailableMissingSecret: string;
1021
1022
  scheduleUnavailableLocal: string;
1022
1023
  scheduleUnavailableDisabledFix: string;
1023
1024
  scheduleUnavailableLocalFix: string;
1025
+ scheduleUnavailableMissingSecretFix: string;
1024
1026
  scheduleUnavailableFixLabel: string;
1025
1027
  scheduleUnavailableFixLabelOpen: string;
1026
1028
  };
@@ -940,11 +940,13 @@ const messages = {
940
940
  scheduleUnknownDetail: "The scheduler status check failed, so the next run times below are unconfirmed. Reload to check again.",
941
941
  scheduleUnavailableTitle: "Schedules won't run in this deploy",
942
942
  scheduleUnavailableLocalTitle: "Schedules don't run in local development",
943
- scheduleUnavailableDisabled: "This app was built with recurring jobs turned off, so no scheduled automation will fire. Event- and webhook-triggered automations and Run now still work.",
944
- scheduleUnavailableNoScheduler: "This hosting target has no durable scheduler, so no scheduled automation will fire. Event- and webhook-triggered automations and Run now still work.",
943
+ scheduleUnavailableDisabled: "This app was built with recurring jobs turned off, so no scheduled automation will fire. Webhook-triggered automations and Run now still work.",
944
+ scheduleUnavailableNoScheduler: "This deploy has no scheduler, so scheduled automations won't fire, event-triggered automations stay queued, and crashed runs aren't recovered. Webhook-triggered automations and Run now still work.",
945
+ scheduleUnavailableMissingSecret: "This deploy has a scheduler, but {{secret}} isn't set, so every scheduler request is rejected. Scheduled automations won't fire, event-triggered automations stay queued, and crashed runs aren't recovered. Webhook-triggered automations and Run now still work.",
945
946
  scheduleUnavailableLocal: "Schedules stay off on a dev machine unless you opt in. Event- and webhook-triggered automations and Run now still work.",
946
947
  scheduleUnavailableDisabledFix: "To enable recurring jobs, set AGENT_NATIVE_DISABLE_RECURRING_JOBS=false in the build environment.",
947
948
  scheduleUnavailableLocalFix: "Set AGENT_NATIVE_ENABLE_LOCAL_RECURRING_JOBS=true to run schedules on this machine.",
949
+ scheduleUnavailableMissingSecretFix: "Set {{secret}} in this deployment's environment variables, then redeploy.",
948
950
  scheduleUnavailableFixLabel: "Show more",
949
951
  scheduleUnavailableFixLabelOpen: "Show less",
950
952
  },
@@ -16,6 +16,6 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
16
16
  } | {
17
17
  count?: undefined;
18
18
  updated?: undefined;
19
- ok: boolean;
20
19
  error?: undefined;
20
+ ok: boolean;
21
21
  }>>;
@@ -45,6 +45,9 @@ export interface AgentToolCallMetric {
45
45
  }
46
46
  /** App-defined tool names collapse to `other` so they cannot mint series. */
47
47
  export declare function recordAgentToolCall(call: AgentToolCallMetric): void;
48
+ export type TraceWriteStage = "spans" | "summary" | "thread_org" | "write";
49
+ /** Counted once per failed stage of a run, never per span. */
50
+ export declare function recordTraceWriteFailure(stage: TraceWriteStage, error: unknown): void;
48
51
  /**
49
52
  * Export buffered telemetry before a serverless function can freeze. Never
50
53
  * delays a request by more than OBSERVABILITY_FLUSH_TIMEOUT_MS; each provider
@@ -72,6 +72,9 @@ function instruments() {
72
72
  flushFailures: meter.createCounter("agent_native.telemetry.flush_failures", {
73
73
  description: "Telemetry flushes that timed out or failed; their points were dropped.",
74
74
  }),
75
+ traceWriteFailures: meter.createCounter("agent_native.observability.trace_write_failures", {
76
+ description: "Runs whose trace spans or summary could not be persisted; their trace is incomplete or missing.",
77
+ }),
75
78
  };
76
79
  return cachedInstruments;
77
80
  }
@@ -154,6 +157,13 @@ export function recordAgentToolCall(call) {
154
157
  ...(call.errorType ? { "error.type": call.errorType } : {}),
155
158
  });
156
159
  }
160
+ /** Counted once per failed stage of a run, never per span. */
161
+ export function recordTraceWriteFailure(stage, error) {
162
+ instruments()?.traceWriteFailures.add(1, {
163
+ "agent_native.observability.stage": stage,
164
+ "error.type": flushErrorType(error),
165
+ });
166
+ }
157
167
  function flushErrorType(error) {
158
168
  return error instanceof Error && error.name ? error.name : "unknown";
159
169
  }
@@ -54,4 +54,26 @@ export const OBSERVABILITY_MIGRATIONS = [
54
54
  AND summary.org_id IS NOT NULL;
55
55
  `,
56
56
  },
57
+ {
58
+ version: 3,
59
+ name: "observability-org-scope-legacy-threads",
60
+ // A thread created without a request org kept org_id NULL, so every review
61
+ // join (thread.org_id = trace org) hid it. Adopt the org only when all of
62
+ // the owner's runs on the thread name the same one.
63
+ sql: `
64
+ UPDATE chat_threads AS thread
65
+ SET org_id = trace_org.org_id
66
+ FROM (
67
+ SELECT thread_id, LOWER(user_id) AS owner_key, MIN(org_id) AS org_id
68
+ FROM agent_trace_summaries
69
+ WHERE thread_id IS NOT NULL AND user_id IS NOT NULL
70
+ AND org_id IS NOT NULL
71
+ GROUP BY thread_id, LOWER(user_id)
72
+ HAVING COUNT(DISTINCT org_id) = 1
73
+ ) AS trace_org
74
+ WHERE thread.org_id IS NULL
75
+ AND thread.id = trace_org.thread_id
76
+ AND LOWER(thread.owner_email) = trace_org.owner_key;
77
+ `,
78
+ },
57
79
  ];
@@ -27,38 +27,43 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
27
27
  thumbsUpRate: number;
28
28
  avgEvalScore: number;
29
29
  } | {
30
+ ok?: undefined;
30
31
  error: string;
31
32
  message: string;
32
33
  summary?: undefined;
33
34
  spans?: undefined;
34
- id?: undefined;
35
- ok?: undefined;
36
35
  } | {
36
+ error?: undefined;
37
+ ok?: undefined;
37
38
  message?: undefined;
38
39
  summary: import("./types.js").TraceSummary;
39
40
  spans: import("./types.js").TraceSpan[];
40
- id?: undefined;
41
+ } | {
41
42
  error?: undefined;
42
43
  ok?: undefined;
43
- } | {
44
44
  message?: undefined;
45
45
  summary?: undefined;
46
46
  spans?: undefined;
47
+ traceMissing: true;
47
48
  id: string;
49
+ } | {
48
50
  error?: undefined;
49
51
  ok?: undefined;
52
+ message?: undefined;
53
+ summary?: undefined;
54
+ spans?: undefined;
55
+ traceMissing?: undefined;
56
+ id: string;
50
57
  } | {
58
+ ok?: undefined;
51
59
  message?: undefined;
52
60
  summary?: undefined;
53
61
  spans?: undefined;
54
- id?: undefined;
55
62
  error: any;
56
- ok?: undefined;
57
63
  } | {
64
+ error?: undefined;
58
65
  message?: undefined;
59
66
  summary?: undefined;
60
67
  spans?: undefined;
61
- id?: undefined;
62
- error?: undefined;
63
68
  ok: boolean;
64
69
  }>>;
@@ -1,6 +1,8 @@
1
1
  import { defineEventHandler, getHeader, getMethod, getQuery, setResponseHeader, setResponseStatus, } from "h3";
2
2
  import { isActionContractError } from "../action.js";
3
+ import { resolveThreadAccess } from "../chat-threads/store.js";
3
4
  import { getOrgContext } from "../org/context.js";
5
+ import { isOrgMember } from "../org/membership.js";
4
6
  import { getSession } from "../server/auth.js";
5
7
  import { readBody } from "../server/h3-helpers.js";
6
8
  import { getRequestContext } from "../server/request-context.js";
@@ -15,6 +17,16 @@ const FEEDBACK_TYPES = [
15
17
  "category",
16
18
  "text",
17
19
  ];
20
+ const MAX_FEEDBACK_VALUE_CHARS = 20_000;
21
+ const MAX_ID_CHARS = 200;
22
+ // An id past the bound is recorded truncated but never looked up: no real id is
23
+ // that long, and its prefix can name a different row.
24
+ function idClaim(value) {
25
+ if (!value)
26
+ return null;
27
+ const id = String(value);
28
+ return { id: id.slice(0, MAX_ID_CHARS), overlong: id.length > MAX_ID_CHARS };
29
+ }
18
30
  function isFeedbackType(value) {
19
31
  return (typeof value === "string" &&
20
32
  FEEDBACK_TYPES.includes(value));
@@ -174,33 +186,90 @@ export function createObservabilityHandler() {
174
186
  return { error: "feedbackType is required" };
175
187
  }
176
188
  const rawValue = body.value;
177
- const value = rawValue == null
189
+ let value = rawValue == null
178
190
  ? ""
179
191
  : typeof rawValue === "object"
180
192
  ? JSON.stringify(rawValue)
181
193
  : String(rawValue);
194
+ if (value.length > MAX_FEEDBACK_VALUE_CHARS) {
195
+ setResponseStatus(event, 413);
196
+ return { error: "Feedback value is too large" };
197
+ }
182
198
  const id = nanoid();
183
199
  const idempotencyKey = feedbackType === "text"
184
200
  ? getHeader(event, "idempotency-key")?.trim() || null
185
201
  : null;
186
202
  const org = await getOrgContext(event);
187
- const runId = body.runId ? String(body.runId) : null;
188
- let threadId = body.threadId ? String(body.threadId) : null;
203
+ const runClaim = idClaim(body.runId);
204
+ const threadClaim = idClaim(body.threadId);
205
+ let runId = runClaim?.id ?? null;
206
+ let threadId = threadClaim?.id ?? null;
189
207
  let model;
190
208
  let orgId = org.orgId;
191
- if (runId) {
192
- const summary = await getTraceSummary(runId, {
193
- userId: owner,
194
- ...(org.orgId ? { orgId: org.orgId } : {}),
195
- });
196
- if (!summary || (threadId && threadId !== summary.threadId)) {
209
+ let unverifiedRunId;
210
+ let unverifiedThreadId;
211
+ // A thread id is the caller's claim until one of their own runs vouches
212
+ // for it or they are shown to have access to the thread.
213
+ let threadVouched = false;
214
+ if (runId && runClaim?.overlong) {
215
+ unverifiedRunId = runId;
216
+ runId = null;
217
+ }
218
+ else if (runId) {
219
+ // Ownership is the user, not the org: a run recorded with no org, or
220
+ // under another of the caller's orgs, is still the caller's own.
221
+ const summary = await getTraceSummary(runId, { userId: owner });
222
+ // Trace writes are fire-and-forget, so a vote can arrive for a run
223
+ // that was never persisted. That is a missing trace, not a run that
224
+ // belongs to someone else: the latter still answers 404.
225
+ const traceMissing = !summary && !(await getTraceSummary(runId));
226
+ if (traceMissing) {
227
+ unverifiedRunId = runId;
228
+ runId = null;
229
+ }
230
+ else if (!summary ||
231
+ (threadId && (threadClaim?.overlong || threadId !== summary.threadId))) {
197
232
  setResponseStatus(event, 404);
198
233
  return { error: "Trace not found" };
199
234
  }
200
- threadId = summary.threadId;
201
- model = summary.model || undefined;
202
- orgId = summary.orgId ?? org.orgId;
235
+ else {
236
+ threadId = summary.threadId;
237
+ threadVouched = true;
238
+ model = summary.model || undefined;
239
+ // The run is the caller's, but it may be recorded under an org they
240
+ // have since left; that org's review data is not theirs to write to.
241
+ if (summary.orgId && summary.orgId !== org.orgId) {
242
+ orgId = (await isOrgMember(summary.orgId, owner))
243
+ ? summary.orgId
244
+ : null;
245
+ }
246
+ }
247
+ }
248
+ if (threadId &&
249
+ !threadVouched &&
250
+ (threadClaim?.overlong ||
251
+ !(await resolveThreadAccess(owner, threadId, "viewer", {
252
+ orgId: org.orgId ?? undefined,
253
+ })))) {
254
+ // The thread may live in another app (a workspace chat rail posts
255
+ // votes to the host for a remote app's thread), so an unverifiable id
256
+ // is not an error. A missing and an inaccessible thread must answer
257
+ // alike, or the route reveals which thread ids exist.
258
+ unverifiedThreadId = threadId;
259
+ threadId = null;
260
+ }
261
+ const traceMissing = !!(unverifiedRunId || unverifiedThreadId);
262
+ if (traceMissing && rawValue && typeof rawValue === "object") {
263
+ value = JSON.stringify({
264
+ ...rawValue,
265
+ traceMissing: true,
266
+ ...(unverifiedRunId ? { unverifiedRunId } : {}),
267
+ ...(unverifiedThreadId ? { unverifiedThreadId } : {}),
268
+ });
203
269
  }
270
+ const traceMissingResult = traceMissing
271
+ ? { traceMissing: true }
272
+ : {};
204
273
  const inserted = await insertFeedback({
205
274
  id,
206
275
  runId,
@@ -215,7 +284,7 @@ export function createObservabilityHandler() {
215
284
  createdAt: Date.now(),
216
285
  });
217
286
  if (!inserted)
218
- return { id };
287
+ return { id, ...traceMissingResult };
219
288
  {
220
289
  const isThumb = feedbackType === "thumbs_up" || feedbackType === "thumbs_down";
221
290
  track("$ai_feedback", {
@@ -230,6 +299,11 @@ export function createObservabilityHandler() {
230
299
  run_id: runId,
231
300
  thread_id: threadId,
232
301
  model,
302
+ ...(traceMissing ? { trace_missing: true } : {}),
303
+ ...(unverifiedRunId ? { unverified_run_id: unverifiedRunId } : {}),
304
+ ...(unverifiedThreadId
305
+ ? { unverified_thread_id: unverifiedThreadId }
306
+ : {}),
233
307
  $ai_trace_id: runId ?? undefined,
234
308
  $ai_session_id: threadId ?? undefined,
235
309
  $ai_model: model,
@@ -255,7 +329,7 @@ export function createObservabilityHandler() {
255
329
  }).catch(() => { }))
256
330
  .catch(() => { });
257
331
  }
258
- return { id };
332
+ return { id, ...traceMissingResult };
259
333
  }
260
334
  if (method === "GET" && parts.length === 1 && parts[0] === "feedback") {
261
335
  setResponseHeader(event, "Cache-Control", "private, no-store");
@@ -15,8 +15,13 @@ export declare function shouldInferSentimentForTurn(args: {
15
15
  hasUserText: boolean;
16
16
  }): boolean;
17
17
  export declare function parseInferredSentiment(output: string): InferredSentiment | null;
18
+ export declare const INFERRED_SENTIMENT_FAILURE_REASONS: readonly ["engine_unavailable", "timeout", "parse_failed", "empty"];
19
+ export type InferredSentimentFailureReason = (typeof INFERRED_SENTIMENT_FAILURE_REASONS)[number];
18
20
  export declare function inferAndTrackSentiment(args: {
21
+ /** Explicit engine override; wins over `runEngine`. */
19
22
  engine?: AgentEngine;
23
+ /** The engine that served the run being classified. */
24
+ runEngine?: AgentEngine;
20
25
  classifierModel: string;
21
26
  precedingResponseModel: string;
22
27
  text: string;
@@ -99,19 +99,35 @@ function truncateInput(text) {
99
99
  .slice(0, INFERRED_SENTIMENT_MAX_CHARS)
100
100
  .join("");
101
101
  }
102
+ export const INFERRED_SENTIMENT_FAILURE_REASONS = [
103
+ "engine_unavailable",
104
+ "timeout",
105
+ "parse_failed",
106
+ "empty",
107
+ ];
108
+ function engineCanRun(engine, model) {
109
+ return (engine.preserveCustomModels === true ||
110
+ engine.supportedModels.length === 0 ||
111
+ engine.supportedModels.includes(model));
112
+ }
102
113
  async function classifySentiment(args) {
103
- if (!args.engine.preserveCustomModels &&
104
- args.engine.supportedModels.length > 0 &&
105
- !args.engine.supportedModels.includes(args.model)) {
106
- return null;
114
+ if (!engineCanRun(args.engine, args.model)) {
115
+ return { failure: "engine_unavailable" };
107
116
  }
108
117
  const input = truncateInput(args.text);
109
118
  if (!input)
110
- return null;
119
+ return { failure: "empty" };
111
120
  const controller = new AbortController();
112
- const timeout = setTimeout(() => controller.abort(), INFERRED_SENTIMENT_TIMEOUT_MS);
121
+ let timedOut = false;
122
+ const timeout = setTimeout(() => {
123
+ timedOut = true;
124
+ controller.abort();
125
+ }, INFERRED_SENTIMENT_TIMEOUT_MS);
113
126
  let output = "";
114
127
  let finalOutput = "";
128
+ const interrupted = () => ({
129
+ failure: timedOut ? "timeout" : "engine_unavailable",
130
+ });
115
131
  try {
116
132
  for await (const event of args.engine.stream({
117
133
  model: args.model,
@@ -133,51 +149,85 @@ async function classifySentiment(args) {
133
149
  .join("");
134
150
  }
135
151
  if (typedEvent.type === "stop" && typedEvent.reason === "error") {
136
- return null;
152
+ return interrupted();
137
153
  }
138
154
  }
139
- return parseInferredSentiment(finalOutput || output);
140
155
  }
141
156
  catch {
142
- return null;
157
+ return interrupted();
143
158
  }
144
159
  finally {
145
160
  clearTimeout(timeout);
146
161
  }
162
+ // An aborted stream can end quietly; its partial text is not a verdict.
163
+ if (timedOut)
164
+ return { failure: "timeout" };
165
+ const verdict = (finalOutput || output).trim();
166
+ if (!verdict)
167
+ return { failure: "empty" };
168
+ const sentiment = parseInferredSentiment(verdict);
169
+ return sentiment ? { sentiment } : { failure: "parse_failed" };
170
+ }
171
+ /**
172
+ * The run's own engine when it can serve the classifier model, so a Builder run
173
+ * classifies with the credentials it already holds. Otherwise the hosted
174
+ * Builder engine; `null` when that cannot even be constructed.
175
+ */
176
+ async function resolveClassifierEngine(args) {
177
+ if (args.engine)
178
+ return args.engine;
179
+ if (args.runEngine && engineCanRun(args.runEngine, args.classifierModel)) {
180
+ return args.runEngine;
181
+ }
182
+ try {
183
+ return (await import("../agent/engine/builder-engine.js")).createBuilderEngine();
184
+ // coercion-ok: the caller reports a null engine as `engine_unavailable`.
185
+ }
186
+ catch {
187
+ return null;
188
+ }
147
189
  }
148
190
  export async function inferAndTrackSentiment(args) {
149
191
  try {
150
192
  if (!shouldSampleInferredSentiment(args.classificationTriggerRunId, args.sampleRate)) {
151
193
  return;
152
194
  }
153
- const engine = args.engine ??
154
- (await import("../agent/engine/builder-engine.js")).createBuilderEngine();
155
- const sentiment = await classifySentiment({
156
- engine,
157
- model: args.classifierModel,
158
- text: args.text,
159
- });
160
- if (!sentiment)
161
- return;
195
+ const engine = await resolveClassifierEngine(args);
196
+ const result = engine
197
+ ? await classifySentiment({
198
+ engine,
199
+ model: args.classifierModel,
200
+ text: args.text,
201
+ })
202
+ : { failure: "engine_unavailable" };
162
203
  const { track } = await import("../tracking/registry.js");
163
- track("$ai_sentiment", {
204
+ const shared = {
164
205
  ...trackingIdentityProperties(),
165
206
  source: "agent_observability",
166
207
  method: "llm",
167
- sentiment,
168
208
  model: args.precedingResponseModel,
169
209
  classifier_model: args.classifierModel,
170
- classifier_engine: engine.name,
171
- attribution: "user_reaction_to_preceding_model",
210
+ classifier_engine: engine?.name,
172
211
  run_id: args.precedingRunId,
173
212
  classification_trigger_run_id: args.classificationTriggerRunId,
174
213
  thread_id: args.threadId,
175
214
  $ai_model: args.precedingResponseModel,
176
215
  $ai_trace_id: args.precedingRunId,
177
216
  $ai_session_id: args.threadId ?? undefined,
178
- }, { userId: args.userId ?? undefined });
217
+ };
218
+ // A classifier that fails must show up as a count, never as a quiet drop
219
+ // in `$ai_sentiment`; the reason is coarse and carries no message content.
220
+ track("failure" in result ? "$ai_sentiment_failed" : "$ai_sentiment", "failure" in result
221
+ ? { ...shared, reason: result.failure }
222
+ : {
223
+ ...shared,
224
+ sentiment: result.sentiment,
225
+ attribution: "user_reaction_to_preceding_model",
226
+ }, { userId: args.userId ?? undefined });
179
227
  }
180
- catch {
181
- // Inference and analytics are both optional and must never affect chat.
228
+ catch (error) {
229
+ // Optional inference must never affect chat, but an unexpected failure
230
+ // outside the classifier must not vanish either.
231
+ console.warn("[agent-native] observability: sentiment inference failed", error instanceof Error ? error.message : String(error));
182
232
  }
183
233
  }
@@ -48,6 +48,34 @@ export declare function getTraceSummary(runId: string, opts?: {
48
48
  userId?: string;
49
49
  orgId?: string;
50
50
  }): Promise<TraceSummary | null>;
51
+ /**
52
+ * A thread created on a path with no request org keeps `org_id` NULL, and every
53
+ * review read requires `thread.org_id` to equal the trace's org, so its runs
54
+ * never reach Human Review. The run that executed under an org is the proof of
55
+ * which org the thread belongs to: adopt it, for the thread's own owner only
56
+ * and only while `org_id` is NULL.
57
+ *
58
+ * A private thread's `org_id` grants nothing but that review access (owner
59
+ * access ignores it, shares do not read it, and `visibility = 'org'` is the only
60
+ * way it scopes sharing), while `thread_data` holds every run's conversation. So
61
+ * a private thread whose owner's runs span more than one org belongs to none of
62
+ * them: the second statement unassigns it. Org-visible threads keep their
63
+ * `org_id`, which is an explicit sharing scope.
64
+ *
65
+ * Both statements run after this run's own summary is committed
66
+ * (`upsertTraceSummary` is awaited first), so of two concurrent runs under
67
+ * different orgs the one whose statements run later always sees the other's
68
+ * committed org. If this run's adopt raced ahead of the other run's summary, the
69
+ * other run's unassign undoes it, or, when that ran before this adopt
70
+ * committed, this run's own unassign does, since it starts after the adopt.
71
+ * No lock is needed.
72
+ *
73
+ * The multi-org signal is derived from `agent_trace_summaries`, so it is only
74
+ * as durable as those rows: retention purges them, and a run that never wrote a
75
+ * summary is invisible to it. A thread unassigned for spanning orgs can
76
+ * therefore be re-adopted once the other org's summaries age out.
77
+ */
78
+ export declare function adoptTraceOrgForThread(summary: Pick<TraceSummary, "threadId" | "userId" | "orgId">): Promise<void>;
51
79
  export declare function getOrgScopedThreadData(orgId: string, ownerEmail: string, threadIds: readonly string[]): Promise<Map<string, string | null>>;
52
80
  export declare function getOrgScopedThreadTitles(orgId: string, ownerEmail: string, threadIds: readonly string[]): Promise<Map<string, string>>;
53
81
  export declare function getOrgScopedReviewThreads(requested: readonly (ObservabilityReviewThreadScope & {