@agent-native/core 0.153.4 → 0.153.6
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/corpus/templates/analytics/actions/account-deep-dive.ts +1 -0
- package/corpus/templates/analytics/actions/bigquery-query.ts +1 -0
- package/corpus/templates/analytics/actions/bigquery.ts +1 -0
- package/corpus/templates/analytics/actions/content-calendar-schema.ts +1 -0
- package/corpus/templates/analytics/actions/content-calendar.ts +1 -0
- package/corpus/templates/analytics/actions/gcloud.ts +1 -0
- package/corpus/templates/analytics/actions/get-data-program.ts +4 -0
- package/corpus/templates/analytics/actions/get-error-issue.ts +1 -0
- package/corpus/templates/analytics/actions/get-monitor-stats.ts +1 -0
- package/corpus/templates/analytics/actions/get-monitor.ts +1 -0
- package/corpus/templates/analytics/actions/get-session-replay-events.ts +1 -0
- package/corpus/templates/analytics/actions/get-session-replay-summary.ts +1 -0
- package/corpus/templates/analytics/actions/get-session-replay-timeline.ts +1 -0
- package/corpus/templates/analytics/actions/gong-calls.ts +1 -0
- package/corpus/templates/analytics/actions/gong-native-insights.ts +1 -0
- package/corpus/templates/analytics/actions/grafana.ts +1 -0
- package/corpus/templates/analytics/actions/hubspot-deals.ts +1 -0
- package/corpus/templates/analytics/actions/hubspot-metrics.ts +1 -0
- package/corpus/templates/analytics/actions/hubspot-pipelines.ts +1 -0
- package/corpus/templates/analytics/actions/hubspot-records.ts +1 -0
- package/corpus/templates/analytics/actions/jira-search.ts +1 -0
- package/corpus/templates/analytics/actions/jira.ts +1 -0
- package/corpus/templates/analytics/actions/list-error-issues.ts +1 -0
- package/corpus/templates/analytics/actions/list-monitors.ts +1 -0
- package/corpus/templates/analytics/actions/list-session-recordings.ts +1 -0
- package/corpus/templates/analytics/actions/match-error-issues.ts +1 -0
- package/corpus/templates/analytics/actions/prometheus.ts +1 -0
- package/corpus/templates/analytics/actions/pylon-issues.ts +1 -0
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -0
- package/corpus/templates/analytics/actions/query-dashboard-panel.ts +1 -0
- package/corpus/templates/analytics/actions/query-inbound-forms.ts +1 -0
- package/corpus/templates/analytics/actions/run-monitor-check.ts +1 -0
- package/corpus/templates/analytics/actions/sentry.ts +1 -0
- package/corpus/templates/analytics/actions/seo-blog-pages.ts +1 -0
- package/corpus/templates/analytics/actions/seo-page-keywords.ts +1 -0
- package/corpus/templates/analytics/actions/seo-top-keywords.ts +1 -0
- package/corpus/templates/analytics/actions/slack-messages.ts +1 -0
- package/corpus/templates/analytics/actions/stripe.ts +1 -0
- package/corpus/templates/analytics/actions/test-custom-api-connection.ts +1 -0
- package/corpus/templates/analytics/server/lib/real-data-actions.ts +62 -47
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +27 -34
- package/dist/action.d.ts +13 -0
- package/dist/action.js +3 -0
- package/dist/agent/engine/tool-call-journal-seed.d.ts +27 -10
- package/dist/agent/engine/tool-call-journal-seed.js +63 -32
- package/dist/agent/production-agent.d.ts +40 -1
- package/dist/agent/production-agent.js +201 -62
- package/dist/agent/run-loop-with-resume.d.ts +1 -1
- package/dist/agent/run-loop-with-resume.js +49 -35
- package/dist/agent/run-manager.js +17 -11
- package/dist/agent/run-store.d.ts +16 -0
- package/dist/agent/run-store.js +51 -9
- package/dist/agent/thread-data-builder.d.ts +1 -0
- package/dist/agent/thread-data-builder.js +15 -2
- package/dist/client/RouteTransitionIndicator.js +1 -1
- package/dist/client/agent-chat-adapter.js +242 -282
- package/dist/client/sse-event-processor.d.ts +7 -0
- package/dist/client/sse-event-processor.js +28 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/client.js +2 -2
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/github-repo-files.js +1 -0
- package/dist/provider-api/actions/provider-api.js +1 -0
- package/dist/provider-api/actions/staged-datasets.js +1 -0
- package/dist/provider-api/corpus-jobs.js +1 -0
- package/dist/server/action-discovery.js +2 -0
- package/dist/server/agent-chat-plugin.js +35 -3
- package/dist/server/realtime-token.d.ts +1 -1
- package/package.json +1 -1
|
@@ -2439,6 +2439,13 @@ async function waitForInterruptedToolLedgerEntry(opts) {
|
|
|
2439
2439
|
* Dropping the echoed input costs nothing: the fault is already named by the
|
|
2440
2440
|
* sentence before it, and two failures that differ ONLY in the arguments they
|
|
2441
2441
|
* echo are the same failure.
|
|
2442
|
+
*
|
|
2443
|
+
* Digits stay. Collapsing them merges failures that are genuinely different:
|
|
2444
|
+
* "Record 41 not found" and "Record 42 not found" are two missing records, so a
|
|
2445
|
+
* seven-item sweep where every item legitimately 404s would trip the
|
|
2446
|
+
* across-arguments breaker at item six. A guard message that varies its own
|
|
2447
|
+
* running tally is fixed where that message is written, not by blinding every
|
|
2448
|
+
* breaker to numbers.
|
|
2442
2449
|
*/
|
|
2443
2450
|
function normalizeToolErrorForBreaker(error) {
|
|
2444
2451
|
return (error
|
|
@@ -2455,6 +2462,61 @@ function rateLimitRecoveryHint(message) {
|
|
|
2455
2462
|
}
|
|
2456
2463
|
return "\n\nProvider rate-limit guidance: stop retrying this provider in this turn. Report the rate limit as a coverage gap, include any evidence already gathered, and ask the user to retry after the provider quota resets if full coverage is required.";
|
|
2457
2464
|
}
|
|
2465
|
+
/**
|
|
2466
|
+
* Tool errors the model has no way to clear: the missing thing lives outside
|
|
2467
|
+
* the turn (a credential, a role grant, a connected account, a runtime, the
|
|
2468
|
+
* user's own approval). Every retry costs a full round-trip carrying the whole
|
|
2469
|
+
* transcript and lands on the identical error, so these stop on the FIRST
|
|
2470
|
+
* occurrence rather than after `MAX_SAME_ERROR_ACROSS_ARGUMENTS` of them.
|
|
2471
|
+
*
|
|
2472
|
+
* The distinction is the one `stopForBigQueryNotConfigured` draws in
|
|
2473
|
+
* templates/analytics: a *configuration* gap is permanent for this turn, a
|
|
2474
|
+
* *query* failure is not. Anything the model can act on — a stale read to
|
|
2475
|
+
* rebase, a bad argument, a full quota it can free, a wrong dataset name, a
|
|
2476
|
+
* statement timeout — must stay out of this list and keep its retries.
|
|
2477
|
+
* Precision over recall: a wrong match kills a turn that would have succeeded,
|
|
2478
|
+
* which is worse than one more retry.
|
|
2479
|
+
*/
|
|
2480
|
+
export function permanentPreconditionRemedy(message) {
|
|
2481
|
+
const trimmed = message.replace(/\s+/g, " ").trim();
|
|
2482
|
+
for (const pattern of PERMANENT_PRECONDITION_PATTERNS) {
|
|
2483
|
+
if (pattern.test(trimmed))
|
|
2484
|
+
return trimmed;
|
|
2485
|
+
}
|
|
2486
|
+
return null;
|
|
2487
|
+
}
|
|
2488
|
+
const PERMANENT_PRECONDITION_PATTERNS = [
|
|
2489
|
+
// "Requires editor role on deck ZJshjrXhjx (have viewer)". The trailing space
|
|
2490
|
+
// is load-bearing: it keeps "must have required property" out.
|
|
2491
|
+
/\brequires? (?:an? |the )?[\w-]+ role\b/i,
|
|
2492
|
+
// "Gemini API key not configured. Save GEMINI_API_KEY in settings." Only the
|
|
2493
|
+
// not-configured/not-set wordings — "not found" is how providers report a
|
|
2494
|
+
// missing dataset or record, which the model can and should correct.
|
|
2495
|
+
/\b(?:api[ -]?keys?|access tokens?|credentials?|secrets?)\b[^.]{0,60}\bnot (?:configured|set|connected|available)\b/i,
|
|
2496
|
+
/\bsave [A-Z][A-Z0-9_]{3,} in (?:the )?settings\b/i,
|
|
2497
|
+
// "Connect Builder.io before indexing a design system from Figma or code."
|
|
2498
|
+
// Case-sensitive, sentence-initial, and followed by a capitalized service
|
|
2499
|
+
// name, because that is the only shape that separates the imperative remedy
|
|
2500
|
+
// from the retryable network failure: "failed to connect to the warehouse
|
|
2501
|
+
// before the deadline" and "Connect timed out, retry first" both satisfy
|
|
2502
|
+
// every looser reading of the same words.
|
|
2503
|
+
/(?:^|[.:!?]\s+)Connect [A-Z][\w.-]*[^;]{0,40}?\b(?:before|first|in settings)\b/,
|
|
2504
|
+
// "Plan mode blocked `update-extension`. Switch to Act mode …" — cleared by
|
|
2505
|
+
// the user approving the plan, never by the model trying again.
|
|
2506
|
+
/\bplan mode blocked\b/i,
|
|
2507
|
+
// The templates' most-copied throw, and always `if (!getRequestUserEmail())`:
|
|
2508
|
+
// one synchronous AsyncLocalStorage read of a value fixed when the request
|
|
2509
|
+
// context was established. It cannot appear and disappear between tool calls
|
|
2510
|
+
// of the same chunk, so retrying inside this turn lands on it again.
|
|
2511
|
+
/\bno authenticated user\b/i,
|
|
2512
|
+
// "SSRF blocked: refusing to fetch private/internal address (…)"
|
|
2513
|
+
/\bssrf blocked\b/i,
|
|
2514
|
+
// Deliberately absent: "only available in …". Retention windows ("only
|
|
2515
|
+
// available from the last 90 days") share its wording and are fixed by
|
|
2516
|
+
// narrowing the range, so it stopped turns that were one argument away from
|
|
2517
|
+
// succeeding. The count-based breaker still ends a genuine runtime gate
|
|
2518
|
+
// after six.
|
|
2519
|
+
];
|
|
2458
2520
|
const SOURCE_SWEEP_TOOL_NAME = /\b(?:api|calls?|deals?|docs?|events?|issues?|messages?|metrics?|provider|query|records?|request|search|source|tickets?|transcripts?)\b/i;
|
|
2459
2521
|
const SOURCE_SWEEP_PROVIDER_TOKEN = /\b(?:amplitude|apollo|bigquery|commonroom|data-source|ga4|github|gong|grafana|hubspot|jira|mixpanel|notion|posthog|postgres|postgresql|pylon|sentry|slack|stripe)\b/i;
|
|
2460
2522
|
const SOURCE_SWEEP_EXCLUDED_TOOLS = new Set([
|
|
@@ -2560,14 +2622,20 @@ function restrictAgentTeamsAfterSourceSweep(tools) {
|
|
|
2560
2622
|
};
|
|
2561
2623
|
});
|
|
2562
2624
|
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Deliberately carries no running call count. This decline is recorded as a
|
|
2627
|
+
* tool error, and the breakers key on that text: a message that reports its own
|
|
2628
|
+
* tally ("already made 13 call(s)") mints a fresh key on every decline, so the
|
|
2629
|
+
* decline that exists to stop a spiral becomes the one thing nothing can count.
|
|
2630
|
+
* The fixed threshold below says the same thing without varying.
|
|
2631
|
+
*/
|
|
2563
2632
|
export function repeatedSourceSweepGuardMessage(opts) {
|
|
2564
2633
|
const threshold = opts.threshold ?? SOURCE_SWEEP_TOOL_CALL_THRESHOLD;
|
|
2565
2634
|
const target = opts.scope === "aggregate"
|
|
2566
2635
|
? "read-only source/search tools"
|
|
2567
2636
|
: "the same read-only source/search tool";
|
|
2568
|
-
return (`Skipped ${opts.toolName}: this turn already
|
|
2569
|
-
|
|
2570
|
-
`${threshold}-call convergence budget. Stop calling ${opts.toolName} ` +
|
|
2637
|
+
return (`Skipped ${opts.toolName}: this turn already exhausted its ` +
|
|
2638
|
+
`${threshold}-call convergence budget for ${target}. Stop calling ${opts.toolName} ` +
|
|
2571
2639
|
`one item at a time and change strategy before answering. If a broader ` +
|
|
2572
2640
|
`read-only bulk/source mechanism is available, use it now: provider API ` +
|
|
2573
2641
|
`catalog/docs/request tools with pagination or staging, code execution ` +
|
|
@@ -2597,7 +2665,6 @@ export function shouldGuardRepeatedSourceSweep(opts) {
|
|
|
2597
2665
|
priorCalls: priorSourceSweepCalls,
|
|
2598
2666
|
message: repeatedSourceSweepGuardMessage({
|
|
2599
2667
|
toolName: opts.toolName,
|
|
2600
|
-
priorCalls: priorSourceSweepCalls,
|
|
2601
2668
|
threshold,
|
|
2602
2669
|
scope: "aggregate",
|
|
2603
2670
|
}),
|
|
@@ -2610,7 +2677,6 @@ export function shouldGuardRepeatedSourceSweep(opts) {
|
|
|
2610
2677
|
priorCalls,
|
|
2611
2678
|
message: repeatedSourceSweepGuardMessage({
|
|
2612
2679
|
toolName: opts.toolName,
|
|
2613
|
-
priorCalls,
|
|
2614
2680
|
threshold,
|
|
2615
2681
|
scope: "tool",
|
|
2616
2682
|
}),
|
|
@@ -3095,29 +3161,50 @@ export async function runAgentLoop(opts) {
|
|
|
3095
3161
|
// calls/results are folded into `toolCallHistory` / `toolResultHistory` so
|
|
3096
3162
|
// final response guards see evidence from earlier chunks of this turn.
|
|
3097
3163
|
const consumedJournalKeys = new Set();
|
|
3098
|
-
const
|
|
3164
|
+
const journalRead = await loadPriorTurnToolCallJournal(opts.threadId, opts.turnId);
|
|
3165
|
+
const toolCallJournal = journalRead.status === "read" ? journalRead.toolCallJournal : null;
|
|
3166
|
+
const journaledPriorToolCalls = journalRead.status === "read" ? journalRead.priorToolCalls : [];
|
|
3167
|
+
const journaledPriorToolResults = journalRead.status === "read" ? journalRead.priorToolResults : [];
|
|
3099
3168
|
toolCallHistory.push(...journaledPriorToolCalls);
|
|
3100
3169
|
toolResultHistory.push(...journaledPriorToolResults);
|
|
3170
|
+
// Every per-turn ceiling below is seeded from that read, and the write-replay
|
|
3171
|
+
// hard block is enforced from it. On a continuation we cannot tell an
|
|
3172
|
+
// already-completed side effect from a fresh one without it, so a failed read
|
|
3173
|
+
// stops the turn instead of quietly resuming with a blank ledger.
|
|
3174
|
+
const unreadableJournalStop = journalRead.status === "unreadable" && isInternalContinuationTurn(messages)
|
|
3175
|
+
? {
|
|
3176
|
+
message: "I stopped because I could not read this turn's run ledger, so I could not tell which steps had already finished. " +
|
|
3177
|
+
"Retrying would risk repeating a step that already completed. Please send the request again.",
|
|
3178
|
+
errorCode: "tool_call_journal_unreadable",
|
|
3179
|
+
details: journalRead.error,
|
|
3180
|
+
}
|
|
3181
|
+
: null;
|
|
3101
3182
|
const readOnlyToolResultCache = seedReadOnlyToolResultsFromHistory(messages, actions, seedReadOnlyToolResultsFromJournal(toolCallJournal, actions));
|
|
3102
3183
|
const duplicateReadOnlyToolCalls = seedDuplicateReadOnlyToolCallsFromHistory(messages, actions);
|
|
3103
3184
|
const writeToolInterruptions = seedWriteToolInterruptionsFromHistory(messages, actions);
|
|
3185
|
+
// All three repetition ledgers are SEEDED from earlier chunks of this turn,
|
|
3186
|
+
// like `toolCallHistory` and `toolResultHistory` above. A fresh Map makes the
|
|
3187
|
+
// limit per-CHUNK rather than per-turn, and a turn may chain up to
|
|
3188
|
+
// MAX_RUN_LOOP_CONTINUATIONS (6) or MAX_BACKGROUND_RUN_LOOP_CONTINUATIONS
|
|
3189
|
+
// (20) of them — so the real ceiling becomes 20x the constant, and a spiral
|
|
3190
|
+
// resumes with a clean slate at every chunk boundary.
|
|
3104
3191
|
const repeatedToolErrors = new Map();
|
|
3105
|
-
|
|
3106
|
-
//
|
|
3107
|
-
// SEEDED from earlier chunks of this turn, like `toolCallHistory` and
|
|
3108
|
-
// `toolResultHistory` above. A fresh Map here would make the limit per-CHUNK
|
|
3109
|
-
// rather than per-turn, and a turn may chain up to MAX_RUN_LOOP_CONTINUATIONS
|
|
3110
|
-
// (6) or MAX_BACKGROUND_RUN_LOOP_CONTINUATIONS (20) of them — so the real
|
|
3111
|
-
// ceiling would be 36 or 120 identical failures, not 6, and a spiral would
|
|
3112
|
-
// resume with a clean slate at every chunk boundary.
|
|
3192
|
+
/** Keyed WITHOUT the arguments — see MAX_SAME_ERROR_ACROSS_ARGUMENTS. */
|
|
3113
3193
|
const repeatedToolErrorsAnyArgs = new Map();
|
|
3114
|
-
|
|
3194
|
+
const repeatedToolCalls = new Map();
|
|
3195
|
+
for (const prior of journaledPriorToolCalls) {
|
|
3196
|
+
const key = toolCallCacheKey(prior.name, prior.input);
|
|
3197
|
+
repeatedToolCalls.set(key, (repeatedToolCalls.get(key) ?? 0) + 1);
|
|
3198
|
+
}
|
|
3199
|
+
for (const prior of journaledPriorToolResults) {
|
|
3115
3200
|
if (!prior.isError)
|
|
3116
3201
|
continue;
|
|
3117
|
-
const
|
|
3118
|
-
|
|
3202
|
+
const normalized = normalizeToolErrorForBreaker(prior.content);
|
|
3203
|
+
const anyArgsKey = `${prior.name}:${normalized}`;
|
|
3204
|
+
repeatedToolErrorsAnyArgs.set(anyArgsKey, (repeatedToolErrorsAnyArgs.get(anyArgsKey) ?? 0) + 1);
|
|
3205
|
+
const errorKey = `${toolCallCacheKey(prior.name, prior.input)}:${normalized}`;
|
|
3206
|
+
repeatedToolErrors.set(errorKey, (repeatedToolErrors.get(errorKey) ?? 0) + 1);
|
|
3119
3207
|
}
|
|
3120
|
-
const repeatedToolCalls = new Map();
|
|
3121
3208
|
let finalGuardRetries = 0;
|
|
3122
3209
|
let emptyFinalResponseRetries = 0;
|
|
3123
3210
|
let iterations = 0;
|
|
@@ -3125,6 +3212,31 @@ export async function runAgentLoop(opts) {
|
|
|
3125
3212
|
// trailing `done` would overwrite the boundary the continuation logic reads.
|
|
3126
3213
|
let endedAtLoopLimit = false;
|
|
3127
3214
|
let terminalActionStop = null;
|
|
3215
|
+
/**
|
|
3216
|
+
* A stop is either a hand-off to the user (the two `input_required` codes) or
|
|
3217
|
+
* a failed run. Failed runs MUST leave a terminal `error` event behind: the
|
|
3218
|
+
* run row's status, `error_code`, `listErroredRuns`, the outcome counters and
|
|
3219
|
+
* the client's retry affordance all key off that event, and a stop that only
|
|
3220
|
+
* streams text is recorded as `status='completed', terminal_reason='done'` —
|
|
3221
|
+
* a guard trip counted as a success.
|
|
3222
|
+
*/
|
|
3223
|
+
const sendTerminalActionStop = (stop) => {
|
|
3224
|
+
if (stop.errorCode === "needs-approval" ||
|
|
3225
|
+
stop.errorCode === "awaiting-user-input") {
|
|
3226
|
+
send({ type: "text", text: stop.message });
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
// Sent ONLY as an error: both the live client and the thread rebuild render
|
|
3230
|
+
// an error event's message as assistant text themselves, so also sending it
|
|
3231
|
+
// as `text` prints the same sentence twice.
|
|
3232
|
+
send({
|
|
3233
|
+
type: "error",
|
|
3234
|
+
error: stop.message,
|
|
3235
|
+
errorCode: stop.errorCode ?? "tool_failed",
|
|
3236
|
+
...(stop.details ? { details: stop.details } : {}),
|
|
3237
|
+
recoverable: false,
|
|
3238
|
+
});
|
|
3239
|
+
};
|
|
3128
3240
|
// Overridden (raised tokens, lowered effort) only after an empty-final-
|
|
3129
3241
|
// response retry below — kept separate from `opts.maxOutputTokens`/
|
|
3130
3242
|
// `opts.reasoningEffort` so the very first attempt is unaffected and later
|
|
@@ -3151,6 +3263,11 @@ export async function runAgentLoop(opts) {
|
|
|
3151
3263
|
while (true) {
|
|
3152
3264
|
if (signal.aborted)
|
|
3153
3265
|
break;
|
|
3266
|
+
if (unreadableJournalStop) {
|
|
3267
|
+
terminalActionStop = unreadableJournalStop;
|
|
3268
|
+
sendTerminalActionStop(unreadableJournalStop);
|
|
3269
|
+
break;
|
|
3270
|
+
}
|
|
3154
3271
|
const turnInputTokens = priorTurnInputTokens + usage.inputTokens;
|
|
3155
3272
|
if (turnInputTokens > maxRunInputTokens) {
|
|
3156
3273
|
// Terminal for the whole TURN, not a chunk boundary: emitting
|
|
@@ -3882,9 +3999,6 @@ export async function runAgentLoop(opts) {
|
|
|
3882
3999
|
// call run. The model cannot populate this — it comes from the request.
|
|
3883
4000
|
const approvedToolCallKeys = new Set(opts.approvedToolCalls ?? []);
|
|
3884
4001
|
const runToolCall = async (toolCall) => {
|
|
3885
|
-
// Counted before any journal/cache short-circuit: serving a repeat from
|
|
3886
|
-
// cache still means the model is asking the same question again.
|
|
3887
|
-
noteRepeatedToolCall(toolCall.name, toolCall.input);
|
|
3888
4002
|
const actionEntry = actions[toolCall.name];
|
|
3889
4003
|
const placeholderNormalization = actionEntry
|
|
3890
4004
|
? normalizeOptionalToolPlaceholders(actionEntry.tool.parameters, toolCall.input)
|
|
@@ -3898,6 +4012,11 @@ export async function runAgentLoop(opts) {
|
|
|
3898
4012
|
if (jsonStringCoercion.changed) {
|
|
3899
4013
|
toolCall = { ...toolCall, input: jsonStringCoercion.input };
|
|
3900
4014
|
}
|
|
4015
|
+
// Count after the same normalization that is persisted in the journal:
|
|
4016
|
+
// a model can send a JSON-encoded object/array, while the action and
|
|
4017
|
+
// ledger both see the coerced value. Serving a repeat from cache still
|
|
4018
|
+
// counts as asking the same question again.
|
|
4019
|
+
noteRepeatedToolCall(toolCall.name, toolCall.input);
|
|
3901
4020
|
const toolInputNormalized = placeholderNormalization.changed || jsonStringCoercion.changed;
|
|
3902
4021
|
const wireToolInput = JSON.stringify(toolCall.input ?? {});
|
|
3903
4022
|
const normalizedToolInput = normalizeToolCallInputForHistory(toolCall.input);
|
|
@@ -3931,6 +4050,21 @@ export async function runAgentLoop(opts) {
|
|
|
3931
4050
|
};
|
|
3932
4051
|
const finalizeToolErrorResult = (rawResult) => {
|
|
3933
4052
|
const sanitizedResult = sanitizeToolErrorText(rawResult);
|
|
4053
|
+
// Counting is the wrong instrument for a precondition the turn cannot
|
|
4054
|
+
// satisfy: six identical round-trips through a missing API key cost the
|
|
4055
|
+
// user minutes and end where the first one did. Classified first so the
|
|
4056
|
+
// remedy reaches them on attempt one.
|
|
4057
|
+
const permanentRemedy = permanentPreconditionRemedy(sanitizedResult);
|
|
4058
|
+
if (permanentRemedy) {
|
|
4059
|
+
requestedActionStop ??= {
|
|
4060
|
+
message: `I stopped because ${toolCall.name} needs a setup step outside this turn — a credential, a role, a connected account, or an approval — before it can run. ` +
|
|
4061
|
+
"Retrying would not have changed it, and anything completed before this is saved.",
|
|
4062
|
+
errorCode: "permanent_precondition",
|
|
4063
|
+
details: sanitizedResult,
|
|
4064
|
+
};
|
|
4065
|
+
return (`Stopped: ${toolCall.name} cannot run until a setup step outside this turn is fixed. ` +
|
|
4066
|
+
`Do not retry it with different arguments. ${sanitizedResult}`);
|
|
4067
|
+
}
|
|
3934
4068
|
const errorKey = `${toolCallCacheKey(toolCall.name, toolCall.input)}:${normalizeToolErrorForBreaker(sanitizedResult)}`;
|
|
3935
4069
|
const count = (repeatedToolErrors.get(errorKey) ?? 0) + 1;
|
|
3936
4070
|
repeatedToolErrors.set(errorKey, count);
|
|
@@ -3946,9 +4080,9 @@ export async function runAgentLoop(opts) {
|
|
|
3946
4080
|
`with different arguments. Last error: ${sanitizedResult}`;
|
|
3947
4081
|
requestedActionStop ??= {
|
|
3948
4082
|
message: `I stopped because the ${toolCall.name} action rejected ${anyArgsCount} different attempts the same way, ` +
|
|
3949
|
-
"so changing the arguments again would not have worked. Anything completed before this is saved.
|
|
3950
|
-
`Error: ${sanitizedResult}`,
|
|
4083
|
+
"so changing the arguments again would not have worked. Anything completed before this is saved.",
|
|
3951
4084
|
errorCode: "repeated_tool_error_across_arguments",
|
|
4085
|
+
details: sanitizedResult,
|
|
3952
4086
|
};
|
|
3953
4087
|
return result;
|
|
3954
4088
|
}
|
|
@@ -3961,15 +4095,23 @@ export async function runAgentLoop(opts) {
|
|
|
3961
4095
|
// can do; never instruct them to "fix the arguments".
|
|
3962
4096
|
requestedActionStop ??= {
|
|
3963
4097
|
message: `I stopped because the ${toolCall.name} action failed ${count} times in a row the same way, ` +
|
|
3964
|
-
"so retrying it again would not have worked. Anything completed before this is saved.
|
|
3965
|
-
`Error: ${sanitizedResult}`,
|
|
4098
|
+
"so retrying it again would not have worked. Anything completed before this is saved.",
|
|
3966
4099
|
errorCode: "repeated_identical_tool_error",
|
|
4100
|
+
details: sanitizedResult,
|
|
3967
4101
|
};
|
|
3968
4102
|
return result;
|
|
3969
4103
|
};
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
4104
|
+
/**
|
|
4105
|
+
* A guard refused to run the tool. Recorded as an ERROR, not as a plain
|
|
4106
|
+
* result: a decline costs a full model round-trip carrying the whole
|
|
4107
|
+
* transcript, and `noteRepeatedToolCall` keys on name+args, so a model
|
|
4108
|
+
* iterating ids mints a fresh key every time and declines forever until
|
|
4109
|
+
* `maxIterations`. Routing through `finalizeToolErrorResult` gives the
|
|
4110
|
+
* existing breakers something to count — no new counter — while the text
|
|
4111
|
+
* the model reads is still the guard's own guidance.
|
|
4112
|
+
*/
|
|
4113
|
+
const declineToolCall = (guidance) => {
|
|
4114
|
+
const result = finalizeToolErrorResult(guidance);
|
|
3973
4115
|
send({
|
|
3974
4116
|
type: "tool_start",
|
|
3975
4117
|
id: toolCall.id,
|
|
@@ -3982,9 +4124,10 @@ export async function runAgentLoop(opts) {
|
|
|
3982
4124
|
tool: toolCall.name,
|
|
3983
4125
|
input: toolCall.input,
|
|
3984
4126
|
result,
|
|
4127
|
+
isError: true,
|
|
3985
4128
|
completedSideEffect: false,
|
|
3986
4129
|
});
|
|
3987
|
-
recordToolResult(result,
|
|
4130
|
+
recordToolResult(result, true);
|
|
3988
4131
|
return {
|
|
3989
4132
|
type: "tool-result",
|
|
3990
4133
|
toolCallId: toolCall.id,
|
|
@@ -3992,31 +4135,13 @@ export async function runAgentLoop(opts) {
|
|
|
3992
4135
|
toolInput: wireToolInput,
|
|
3993
4136
|
content: result,
|
|
3994
4137
|
};
|
|
4138
|
+
};
|
|
4139
|
+
if (sourceSweepGuard) {
|
|
4140
|
+
sourceSweepDelegationGuardActive = true;
|
|
4141
|
+
return declineToolCall(sourceSweepGuard.message);
|
|
3995
4142
|
}
|
|
3996
4143
|
if (sourceSweepDelegationGuard) {
|
|
3997
|
-
|
|
3998
|
-
send({
|
|
3999
|
-
type: "tool_start",
|
|
4000
|
-
id: toolCall.id,
|
|
4001
|
-
tool: toolCall.name,
|
|
4002
|
-
input: toolCall.input,
|
|
4003
|
-
});
|
|
4004
|
-
send({
|
|
4005
|
-
type: "tool_done",
|
|
4006
|
-
id: toolCall.id,
|
|
4007
|
-
tool: toolCall.name,
|
|
4008
|
-
input: toolCall.input,
|
|
4009
|
-
result,
|
|
4010
|
-
completedSideEffect: false,
|
|
4011
|
-
});
|
|
4012
|
-
recordToolResult(result, false);
|
|
4013
|
-
return {
|
|
4014
|
-
type: "tool-result",
|
|
4015
|
-
toolCallId: toolCall.id,
|
|
4016
|
-
toolName: toolCall.name,
|
|
4017
|
-
toolInput: wireToolInput,
|
|
4018
|
-
content: result,
|
|
4019
|
-
};
|
|
4144
|
+
return declineToolCall(sourceSweepDelegationGuard);
|
|
4020
4145
|
}
|
|
4021
4146
|
if (!actionEntry) {
|
|
4022
4147
|
const result = finalizeToolErrorResult(`Error: Unknown tool "${toolCall.name}"`);
|
|
@@ -4817,7 +4942,7 @@ export async function runAgentLoop(opts) {
|
|
|
4817
4942
|
// TypeScript can't track ??= through async closures; cast to known type.
|
|
4818
4943
|
const stop = requestedActionStop;
|
|
4819
4944
|
terminalActionStop = stop;
|
|
4820
|
-
|
|
4945
|
+
sendTerminalActionStop(stop);
|
|
4821
4946
|
break;
|
|
4822
4947
|
}
|
|
4823
4948
|
}
|
|
@@ -4893,6 +5018,10 @@ export async function runAgentLoop(opts) {
|
|
|
4893
5018
|
}
|
|
4894
5019
|
}
|
|
4895
5020
|
}
|
|
5021
|
+
// Assignments happen inside async tool-loop closures, which TypeScript cannot
|
|
5022
|
+
// use for narrowing at this point. Snapshot the runtime value before the
|
|
5023
|
+
// outcome branches so a stop is still represented as a possible value.
|
|
5024
|
+
const finalTerminalActionStop = terminalActionStop;
|
|
4896
5025
|
if (signal.aborted) {
|
|
4897
5026
|
reportOutcome({ state: "canceled", message: "Agent run was aborted." });
|
|
4898
5027
|
}
|
|
@@ -4904,26 +5033,26 @@ export async function runAgentLoop(opts) {
|
|
|
4904
5033
|
message: `Agent stopped after ${maxIterations} iterations.`,
|
|
4905
5034
|
});
|
|
4906
5035
|
}
|
|
4907
|
-
else if (
|
|
5036
|
+
else if (finalTerminalActionStop?.errorCode === "needs-approval") {
|
|
4908
5037
|
reportOutcome({
|
|
4909
5038
|
state: "input_required",
|
|
4910
5039
|
code: "needs_approval",
|
|
4911
|
-
message:
|
|
5040
|
+
message: finalTerminalActionStop.message,
|
|
4912
5041
|
});
|
|
4913
5042
|
}
|
|
4914
|
-
else if (
|
|
5043
|
+
else if (finalTerminalActionStop?.errorCode === "awaiting-user-input") {
|
|
4915
5044
|
reportOutcome({
|
|
4916
5045
|
state: "input_required",
|
|
4917
5046
|
code: "awaiting_user_input",
|
|
4918
|
-
message:
|
|
5047
|
+
message: finalTerminalActionStop.message,
|
|
4919
5048
|
});
|
|
4920
5049
|
}
|
|
4921
|
-
else if (
|
|
5050
|
+
else if (finalTerminalActionStop) {
|
|
4922
5051
|
reportOutcome({
|
|
4923
5052
|
state: "failed",
|
|
4924
|
-
code:
|
|
5053
|
+
code: finalTerminalActionStop.errorCode ?? "tool_failed",
|
|
4925
5054
|
retryable: false,
|
|
4926
|
-
message:
|
|
5055
|
+
message: finalTerminalActionStop.message,
|
|
4927
5056
|
});
|
|
4928
5057
|
}
|
|
4929
5058
|
else {
|
|
@@ -4944,6 +5073,13 @@ function isRecoverableContinuationError(event) {
|
|
|
4944
5073
|
const message = event.error.toLowerCase();
|
|
4945
5074
|
if (code === "builder_gateway_error")
|
|
4946
5075
|
return false;
|
|
5076
|
+
// An explicit flag outranks the message sniff below, which exists for events
|
|
5077
|
+
// that carry no flag at all. Every guard stop sets `recoverable: false`, and
|
|
5078
|
+
// sniffing its prose is how a stop that merely NAMES a connection or a
|
|
5079
|
+
// timeout gets continued into the spiral it was raised to end. The client
|
|
5080
|
+
// applies the same precedence in `isAutoRecoverableError`.
|
|
5081
|
+
if (event.recoverable === false)
|
|
5082
|
+
return false;
|
|
4947
5083
|
return (event.recoverable === true ||
|
|
4948
5084
|
code === "builder_gateway_timeout" ||
|
|
4949
5085
|
code === "builder_gateway_network_error" ||
|
|
@@ -5383,8 +5519,11 @@ async function emitRunText(run, text) {
|
|
|
5383
5519
|
* per-turn journal the resume note uses (no new ledger read shape).
|
|
5384
5520
|
*/
|
|
5385
5521
|
async function describeTurnProgress(threadId, turnId) {
|
|
5386
|
-
const
|
|
5387
|
-
|
|
5522
|
+
const journalRead = await loadPriorTurnToolCallJournal(threadId, turnId);
|
|
5523
|
+
if (journalRead.status === "unreadable") {
|
|
5524
|
+
return "I could not read the run ledger, so I cannot say which steps completed.";
|
|
5525
|
+
}
|
|
5526
|
+
const completed = journalRead.toolCallJournal?.completed ?? [];
|
|
5388
5527
|
if (completed.length === 0)
|
|
5389
5528
|
return "No steps had completed yet.";
|
|
5390
5529
|
const names = [...new Set(completed.map((entry) => entry.tool))];
|
|
@@ -27,7 +27,7 @@ import type { ResolveRunSoftTimeoutOptions } from "./run-manager.js";
|
|
|
27
27
|
* Rebuild the same safe continuation context for a logical turn that resumes
|
|
28
28
|
* in a fresh hosted invocation.
|
|
29
29
|
*/
|
|
30
|
-
export declare function appendDurableContinuationContext(messages: EngineMessage[], reason: AgentLoopContinuationReason, threadId: string): Promise<void>;
|
|
30
|
+
export declare function appendDurableContinuationContext(messages: EngineMessage[], reason: AgentLoopContinuationReason, threadId: string, turnId?: string): Promise<void>;
|
|
31
31
|
/**
|
|
32
32
|
* Cap on continuation iterations inside a single
|
|
33
33
|
* `runAgentLoopDirectWithSoftTimeout` invocation. The host's hard function
|
|
@@ -24,23 +24,35 @@ import { runAgentLoop, appendAgentLoopContinuation, isResumableEngineError, isTr
|
|
|
24
24
|
import { resolveRunSoftTimeoutMs } from "./run-manager.js";
|
|
25
25
|
import { getCurrentTurnEventsForThread } from "./run-store.js";
|
|
26
26
|
import { classifyToolCallJournal, buildResumeJournalNote, } from "./tool-call-journal.js";
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* `persisted: false` means the durable half of the turn could not be read, so
|
|
29
|
+
* `events` holds only what this invocation saw. Callers that decide whether to
|
|
30
|
+
* DISCARD user-visible output must not read that as "nothing else happened".
|
|
31
|
+
*/
|
|
32
|
+
async function readCurrentTurnEventsForResume(threadId, turnId, localEvents = []) {
|
|
28
33
|
let persistedEvents = [];
|
|
34
|
+
let persisted = true;
|
|
29
35
|
try {
|
|
36
|
+
// Without the turnId this can return the PREVIOUS turn's events — see
|
|
37
|
+
// `getCurrentTurnEventsForThread`, whose run row is written without await.
|
|
30
38
|
persistedEvents = threadId
|
|
31
|
-
? await getCurrentTurnEventsForThread(threadId)
|
|
39
|
+
? await getCurrentTurnEventsForThread(threadId, turnId)
|
|
32
40
|
: [];
|
|
33
41
|
}
|
|
34
|
-
catch {
|
|
35
|
-
|
|
42
|
+
catch (err) {
|
|
43
|
+
persisted = false;
|
|
44
|
+
console.warn("[run-loop] current-turn ledger read failed:", err instanceof Error ? err.message : String(err));
|
|
36
45
|
}
|
|
37
46
|
if (localEvents.length === 0)
|
|
38
|
-
return persistedEvents;
|
|
47
|
+
return { events: persistedEvents, persisted };
|
|
39
48
|
const seen = new Set(persistedEvents.map((event) => JSON.stringify(event)));
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
return {
|
|
50
|
+
events: [
|
|
51
|
+
...persistedEvents,
|
|
52
|
+
...localEvents.filter((event) => !seen.has(JSON.stringify(event))),
|
|
53
|
+
],
|
|
54
|
+
persisted,
|
|
55
|
+
};
|
|
44
56
|
}
|
|
45
57
|
function actionPreparationContinuationOptions(events) {
|
|
46
58
|
const actionPreparationTool = lastUnfinishedPreparingActionToolFromEvents(events);
|
|
@@ -86,8 +98,8 @@ function appendToolCallJournalNote(messages, events) {
|
|
|
86
98
|
// up — the model still continues, just without the structured journal.
|
|
87
99
|
}
|
|
88
100
|
}
|
|
89
|
-
async function appendContinuationAndJournal(messages, reason, threadId, localEvents = []) {
|
|
90
|
-
const events = await readCurrentTurnEventsForResume(threadId, localEvents);
|
|
101
|
+
async function appendContinuationAndJournal(messages, reason, threadId, turnId, localEvents = []) {
|
|
102
|
+
const { events } = await readCurrentTurnEventsForResume(threadId, turnId, localEvents);
|
|
91
103
|
appendAgentLoopContinuation(messages, reason, actionPreparationContinuationOptions(events));
|
|
92
104
|
appendToolCallJournalNote(messages, events);
|
|
93
105
|
}
|
|
@@ -95,21 +107,23 @@ async function appendContinuationAndJournal(messages, reason, threadId, localEve
|
|
|
95
107
|
* Rebuild the same safe continuation context for a logical turn that resumes
|
|
96
108
|
* in a fresh hosted invocation.
|
|
97
109
|
*/
|
|
98
|
-
export async function appendDurableContinuationContext(messages, reason, threadId) {
|
|
99
|
-
await appendContinuationAndJournal(messages, reason, threadId);
|
|
110
|
+
export async function appendDurableContinuationContext(messages, reason, threadId, turnId) {
|
|
111
|
+
await appendContinuationAndJournal(messages, reason, threadId, turnId);
|
|
100
112
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
+
/**
|
|
114
|
+
* `"unknown"` is not `"none"`: the only caller uses this to decide whether to
|
|
115
|
+
* emit `clear`, and `clear` WIPES user-visible output. A transient ledger error
|
|
116
|
+
* must not delete the tool cards that are the user's only proof a side effect
|
|
117
|
+
* landed.
|
|
118
|
+
*/
|
|
119
|
+
async function completedSideEffectInCurrentTurn(threadId, turnId, localEvents = []) {
|
|
120
|
+
const { events, persisted } = await readCurrentTurnEventsForResume(threadId, turnId, localEvents);
|
|
121
|
+
const found = events.some((event) => event.type === "tool_done" &&
|
|
122
|
+
event.completedSideEffect === true &&
|
|
123
|
+
event.isError !== true);
|
|
124
|
+
if (found)
|
|
125
|
+
return "some";
|
|
126
|
+
return persisted ? "none" : "unknown";
|
|
113
127
|
}
|
|
114
128
|
function internalContinuationReasonForAttempt(events) {
|
|
115
129
|
const last = events.at(-1);
|
|
@@ -362,15 +376,15 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
|
|
|
362
376
|
if (internalContinuationReason && !upstreamSignal.aborted) {
|
|
363
377
|
lastAttemptWasUnfinishedContinuation = true;
|
|
364
378
|
const continuationEvents = [...localTurnEvents];
|
|
365
|
-
if (
|
|
379
|
+
if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, continuationEvents)) === "none") {
|
|
366
380
|
opts.send({ type: "clear" });
|
|
367
381
|
}
|
|
368
|
-
await appendContinuationAndJournal(opts.messages, internalContinuationReason, opts.threadId, continuationEvents);
|
|
382
|
+
await appendContinuationAndJournal(opts.messages, internalContinuationReason, opts.threadId, opts.turnId, continuationEvents);
|
|
369
383
|
continue;
|
|
370
384
|
}
|
|
371
385
|
if (softTimedOut && !upstreamSignal.aborted) {
|
|
372
386
|
lastAttemptWasUnfinishedContinuation = true;
|
|
373
|
-
await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, localTurnEvents);
|
|
387
|
+
await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, opts.turnId, localTurnEvents);
|
|
374
388
|
continue;
|
|
375
389
|
}
|
|
376
390
|
reportFinalOutcome(upstreamSignal.aborted
|
|
@@ -383,10 +397,10 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
|
|
|
383
397
|
// Clear partial text the client received before the abort so the
|
|
384
398
|
// resumed model doesn't re-emit it and produce duplicated output.
|
|
385
399
|
lastAttemptWasUnfinishedContinuation = true;
|
|
386
|
-
if (
|
|
400
|
+
if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
|
|
387
401
|
opts.send({ type: "clear" });
|
|
388
402
|
}
|
|
389
|
-
await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, localTurnEvents);
|
|
403
|
+
await appendContinuationAndJournal(opts.messages, "run_timeout", opts.threadId, opts.turnId, localTurnEvents);
|
|
390
404
|
continue;
|
|
391
405
|
}
|
|
392
406
|
const transientRateLimit = isTransientProviderRateLimitError(err);
|
|
@@ -402,10 +416,10 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
|
|
|
402
416
|
rateLimitRetryFitsBudget) {
|
|
403
417
|
lastAttemptWasUnfinishedContinuation = true;
|
|
404
418
|
backgroundRateLimitContinuations++;
|
|
405
|
-
if (
|
|
419
|
+
if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
|
|
406
420
|
opts.send({ type: "clear" });
|
|
407
421
|
}
|
|
408
|
-
await appendContinuationAndJournal(opts.messages, "rate_limited", opts.threadId, localTurnEvents);
|
|
422
|
+
await appendContinuationAndJournal(opts.messages, "rate_limited", opts.threadId, opts.turnId, localTurnEvents);
|
|
409
423
|
await waitForBackgroundRateLimitCooldown(upstreamSignal);
|
|
410
424
|
continue;
|
|
411
425
|
}
|
|
@@ -424,10 +438,10 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
|
|
|
424
438
|
// the in-memory messages array, so the next attempt re-emits it).
|
|
425
439
|
if (!upstreamSignal.aborted && isResumableEngineError(err)) {
|
|
426
440
|
lastAttemptWasUnfinishedContinuation = true;
|
|
427
|
-
if (
|
|
441
|
+
if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
|
|
428
442
|
opts.send({ type: "clear" });
|
|
429
443
|
}
|
|
430
|
-
await appendContinuationAndJournal(opts.messages, continuationReasonForResumableError(err), opts.threadId, localTurnEvents);
|
|
444
|
+
await appendContinuationAndJournal(opts.messages, continuationReasonForResumableError(err), opts.threadId, opts.turnId, localTurnEvents);
|
|
431
445
|
continue;
|
|
432
446
|
}
|
|
433
447
|
if (upstreamSignal.aborted) {
|
|
@@ -467,7 +481,7 @@ export async function runAgentLoopDirectWithSoftTimeout(opts, softTimeoutMs, tim
|
|
|
467
481
|
// the terminal message stands alone instead of trailing a half sentence.
|
|
468
482
|
// Preserve completed tool cards: they are the user's only durable proof
|
|
469
483
|
// that a side effect landed before the final assistant note timed out.
|
|
470
|
-
if (
|
|
484
|
+
if ((await completedSideEffectInCurrentTurn(opts.threadId, opts.turnId, localTurnEvents)) === "none") {
|
|
471
485
|
opts.send({ type: "clear" });
|
|
472
486
|
}
|
|
473
487
|
opts.send({
|