@agent-native/core 0.101.4 → 0.101.5
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/core/CHANGELOG.md +7 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/action.ts +15 -0
- package/corpus/core/src/agent/production-agent.ts +215 -19
- package/corpus/core/src/coding-tools/run-code.ts +11 -5
- package/corpus/core/src/extensions/actions.ts +15 -0
- package/corpus/core/src/extensions/url-safety.ts +11 -1
- package/corpus/core/src/notifications/channels.ts +61 -30
- package/corpus/core/src/server/action-discovery.ts +4 -1
- package/corpus/core/src/server/agent-chat/context-tools.ts +4 -0
- package/dist/action.d.ts +12 -0
- package/dist/action.d.ts.map +1 -1
- package/dist/action.js +2 -0
- package/dist/action.js.map +1 -1
- package/dist/agent/production-agent.d.ts +24 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +175 -18
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/coding-tools/run-code.d.ts +5 -2
- package/dist/coding-tools/run-code.d.ts.map +1 -1
- package/dist/coding-tools/run-code.js +11 -5
- package/dist/coding-tools/run-code.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/extensions/actions.d.ts.map +1 -1
- package/dist/extensions/actions.js +14 -0
- package/dist/extensions/actions.js.map +1 -1
- package/dist/extensions/url-safety.d.ts +6 -0
- package/dist/extensions/url-safety.d.ts.map +1 -1
- package/dist/extensions/url-safety.js +6 -0
- package/dist/extensions/url-safety.js.map +1 -1
- package/dist/notifications/channels.d.ts.map +1 -1
- package/dist/notifications/channels.js +38 -22
- package/dist/notifications/channels.js.map +1 -1
- package/dist/observability/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +4 -1
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/agent-chat/context-tools.d.ts.map +1 -1
- package/dist/server/agent-chat/context-tools.js +4 -0
- package/dist/server/agent-chat/context-tools.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +3 -3
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.101.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9dd88f4: Opt the dedicated `get-code-execution` and `refresh-screen` volatile reads out of the duplicate read-only tool-call guard via the new `dedupe: false` action option while retaining default duplicate protection for normal `run-code` executions. Also raise `get-extension` and `get-extension-history-version` result caps to 500,000 and 2,000,000 characters respectively so JSON serialization overhead cannot slice mid-content and corrupt source reads for large extensions or their history.
|
|
8
|
+
- 9dd88f4: Prevent repeated read-only tool loops while preserving trimmed results, allow volatile reads to opt out of deduping, and enforce notification webhook allowlists at the scope that supplied each secret.
|
|
9
|
+
|
|
3
10
|
## 0.101.4
|
|
4
11
|
|
|
5
12
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.101.
|
|
3
|
+
"version": "0.101.5",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -373,6 +373,14 @@ interface DefineActionWithSchema<
|
|
|
373
373
|
* Only set this for mutating actions that are internally concurrency-safe
|
|
374
374
|
* and order-independent for same-turn execution. */
|
|
375
375
|
parallelSafe?: boolean;
|
|
376
|
+
/** Set false to exempt a read-only tool from the agent loop's duplicate
|
|
377
|
+
* read-only call guard (per-turn result cache + "Skipped duplicate..."
|
|
378
|
+
* repeat detection). Default true (deduped). Use this for volatile/polling
|
|
379
|
+
* reads where an identical call is expected to return a different result
|
|
380
|
+
* each time — e.g. polling a code-execution status by id, or re-fetching
|
|
381
|
+
* current on-screen state. Has no effect on non-read-only actions, which
|
|
382
|
+
* are never deduped in the first place. */
|
|
383
|
+
dedupe?: boolean;
|
|
376
384
|
/** Whether this action may be invoked from the tools (Alpine iframe) bridge
|
|
377
385
|
* via `appAction(name, params)` — see `packages/core/docs/content/actions.mdx`
|
|
378
386
|
* ("Tools Callability"). **Default-allow opt-out**: undefined / `true` both
|
|
@@ -493,6 +501,9 @@ interface DefineActionWithParams<
|
|
|
493
501
|
/** If true, the agent may execute this action concurrently with other
|
|
494
502
|
* read-only or parallel-safe tool calls emitted in the same model turn. */
|
|
495
503
|
parallelSafe?: boolean;
|
|
504
|
+
/** Set false to exempt a read-only tool from the duplicate read-only call
|
|
505
|
+
* guard. Default true. See the schema overload above. */
|
|
506
|
+
dedupe?: boolean;
|
|
496
507
|
/** Whether this action may be invoked from the tools (Alpine iframe) bridge
|
|
497
508
|
* via `appAction(name, params)`. See the schema overload above for details
|
|
498
509
|
* and the `toolCallable` section in actions.md. */
|
|
@@ -557,6 +568,7 @@ export interface ActionDefinition<TInput, TReturn> {
|
|
|
557
568
|
readonly readOnly?: boolean;
|
|
558
569
|
readonly allowInPlanMode?: boolean;
|
|
559
570
|
readonly parallelSafe?: boolean;
|
|
571
|
+
readonly dedupe?: boolean;
|
|
560
572
|
readonly toolCallable?: boolean;
|
|
561
573
|
readonly publicAgent?: PublicAgentActionConfig;
|
|
562
574
|
readonly link?: ActionLinkBuilder;
|
|
@@ -752,6 +764,8 @@ export function defineAction(options: any) {
|
|
|
752
764
|
typeof options.parallelSafe === "boolean"
|
|
753
765
|
? options.parallelSafe
|
|
754
766
|
: undefined;
|
|
767
|
+
const dedupe: boolean | undefined =
|
|
768
|
+
typeof options.dedupe === "boolean" ? options.dedupe : undefined;
|
|
755
769
|
const publicAgent: PublicAgentActionConfig | undefined =
|
|
756
770
|
options.publicAgent &&
|
|
757
771
|
typeof options.publicAgent === "object" &&
|
|
@@ -806,6 +820,7 @@ export function defineAction(options: any) {
|
|
|
806
820
|
? { allowInPlanMode: options.allowInPlanMode }
|
|
807
821
|
: {}),
|
|
808
822
|
...(typeof parallelSafe === "boolean" ? { parallelSafe } : {}),
|
|
823
|
+
...(typeof dedupe === "boolean" ? { dedupe } : {}),
|
|
809
824
|
...(typeof toolCallable === "boolean" ? { toolCallable } : {}),
|
|
810
825
|
...(publicAgent ? { publicAgent } : {}),
|
|
811
826
|
...(link ? { link } : {}),
|
|
@@ -583,6 +583,11 @@ export interface ActionEntry {
|
|
|
583
583
|
* read-only/parallel-safe tool calls. Only use for actions that handle
|
|
584
584
|
* their own write ordering and idempotency. */
|
|
585
585
|
parallelSafe?: boolean;
|
|
586
|
+
/** Set false to exempt a read-only tool from the duplicate read-only
|
|
587
|
+
* tool-call guard (per-turn result cache + repeat-kill). Default true. Use
|
|
588
|
+
* for volatile/polling reads that are expected to return a different
|
|
589
|
+
* result on each identical call. See `defineAction`'s `dedupe` option. */
|
|
590
|
+
dedupe?: boolean;
|
|
586
591
|
/** Whether this action may be invoked from the tools-iframe bridge.
|
|
587
592
|
* **Default-allow opt-out**: only an explicit `false` returns 403.
|
|
588
593
|
* - `true` / `undefined` — allow.
|
|
@@ -2170,16 +2175,26 @@ function seedReadOnlyToolResultsFromHistory(
|
|
|
2170
2175
|
const cache = new Map<string, string>();
|
|
2171
2176
|
if (!isInternalContinuationTurn(messages)) return cache;
|
|
2172
2177
|
|
|
2173
|
-
|
|
2174
|
-
|
|
2178
|
+
// Scoped to the current turn only (same slice as
|
|
2179
|
+
// seedWriteToolInterruptionsFromHistory) — reads from a prior turn are no
|
|
2180
|
+
// longer relevant context and must not seed skip-as-duplicate behavior.
|
|
2181
|
+
const turnStart = findCurrentTurnStartForContinuation(messages);
|
|
2182
|
+
const turnMessages = messages.slice(turnStart);
|
|
2183
|
+
|
|
2184
|
+
const pendingToolCalls = new Map<
|
|
2185
|
+
string,
|
|
2186
|
+
{ name: string; input: unknown; readOnly: boolean; dedupe: boolean }
|
|
2187
|
+
>();
|
|
2188
|
+
for (const message of turnMessages) {
|
|
2175
2189
|
if (message.role === "assistant") {
|
|
2176
2190
|
for (const part of message.content) {
|
|
2177
2191
|
if (part.type !== "tool-call") continue;
|
|
2178
2192
|
const entry = actions[part.name];
|
|
2179
|
-
if (entry?.readOnly !== true) continue;
|
|
2180
2193
|
pendingToolCalls.set(part.id, {
|
|
2181
2194
|
name: part.name,
|
|
2182
2195
|
input: part.input,
|
|
2196
|
+
readOnly: entry?.readOnly === true,
|
|
2197
|
+
dedupe: entry?.dedupe !== false,
|
|
2183
2198
|
});
|
|
2184
2199
|
}
|
|
2185
2200
|
continue;
|
|
@@ -2189,6 +2204,17 @@ function seedReadOnlyToolResultsFromHistory(
|
|
|
2189
2204
|
if (part.type !== "tool-result") continue;
|
|
2190
2205
|
const call = pendingToolCalls.get(part.toolCallId);
|
|
2191
2206
|
if (!call) continue;
|
|
2207
|
+
if (!call.readOnly) {
|
|
2208
|
+
// Mirror the live loop: a successful write invalidates all cached
|
|
2209
|
+
// reads (see the `readOnlyToolResultCache.clear()` call below), so a
|
|
2210
|
+
// read seeded from before an intervening write must not be replayed
|
|
2211
|
+
// as still-fresh.
|
|
2212
|
+
if (part.isError !== true) cache.clear();
|
|
2213
|
+
continue;
|
|
2214
|
+
}
|
|
2215
|
+
// dedupe:false read-only tools (volatile/polling reads) are never
|
|
2216
|
+
// cached — every call must execute fresh, seeded or not.
|
|
2217
|
+
if (!call.dedupe) continue;
|
|
2192
2218
|
if (!isReusableReadOnlyToolResult(part)) continue;
|
|
2193
2219
|
cache.set(toolCallCacheKey(call.name, call.input), part.content);
|
|
2194
2220
|
}
|
|
@@ -2197,11 +2223,100 @@ function seedReadOnlyToolResultsFromHistory(
|
|
|
2197
2223
|
return cache;
|
|
2198
2224
|
}
|
|
2199
2225
|
|
|
2226
|
+
function visibleDuplicateReadOnlyToolResult(toolName: string): string {
|
|
2227
|
+
return (
|
|
2228
|
+
`Skipped duplicate read-only call to ${toolName}: identical input already ran in this turn. ` +
|
|
2229
|
+
`Use the previous result already in the conversation instead of calling this tool again.`
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
function resurfacedDuplicateReadOnlyToolResultPrefix(toolName: string): string {
|
|
2234
|
+
return (
|
|
2235
|
+
`Skipped duplicate read-only call to ${toolName}: identical input already ran in this turn. ` +
|
|
2236
|
+
`Its earlier result is no longer in view, so here it is again:\n\n`
|
|
2237
|
+
);
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function resurfacedDuplicateReadOnlyToolResult(
|
|
2241
|
+
toolName: string,
|
|
2242
|
+
cachedResult: string,
|
|
2243
|
+
): string {
|
|
2244
|
+
return `${resurfacedDuplicateReadOnlyToolResultPrefix(toolName)}${cachedResult}`;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/** Restore visible-repeat strike counts for this continuation's active turn. */
|
|
2248
|
+
function seedDuplicateReadOnlyToolCallsFromHistory(
|
|
2249
|
+
messages: EngineMessage[],
|
|
2250
|
+
actions: Record<string, ActionEntry>,
|
|
2251
|
+
): Map<string, number> {
|
|
2252
|
+
const repeats = new Map<string, number>();
|
|
2253
|
+
if (!isInternalContinuationTurn(messages)) return repeats;
|
|
2254
|
+
|
|
2255
|
+
const turnStart = findCurrentTurnStartForContinuation(messages);
|
|
2256
|
+
const pendingToolCalls = new Map<
|
|
2257
|
+
string,
|
|
2258
|
+
{ name: string; input: unknown; readOnly: boolean; dedupe: boolean }
|
|
2259
|
+
>();
|
|
2260
|
+
const reusableReadKeys = new Set<string>();
|
|
2261
|
+
|
|
2262
|
+
for (const message of messages.slice(turnStart)) {
|
|
2263
|
+
if (message.role === "assistant") {
|
|
2264
|
+
for (const part of message.content) {
|
|
2265
|
+
if (part.type !== "tool-call") continue;
|
|
2266
|
+
const entry = actions[part.name];
|
|
2267
|
+
pendingToolCalls.set(part.id, {
|
|
2268
|
+
name: part.name,
|
|
2269
|
+
input: part.input,
|
|
2270
|
+
readOnly: entry?.readOnly === true,
|
|
2271
|
+
dedupe: entry?.dedupe !== false,
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
for (const part of message.content) {
|
|
2278
|
+
if (part.type !== "tool-result") continue;
|
|
2279
|
+
const call = pendingToolCalls.get(part.toolCallId);
|
|
2280
|
+
if (!call) continue;
|
|
2281
|
+
if (!call.readOnly) {
|
|
2282
|
+
if (part.isError !== true) {
|
|
2283
|
+
repeats.clear();
|
|
2284
|
+
reusableReadKeys.clear();
|
|
2285
|
+
}
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
if (!call.dedupe || part.isError === true) continue;
|
|
2289
|
+
|
|
2290
|
+
const cacheKey = toolCallCacheKey(call.name, call.input);
|
|
2291
|
+
if (part.content === visibleDuplicateReadOnlyToolResult(call.name)) {
|
|
2292
|
+
if (reusableReadKeys.has(cacheKey)) {
|
|
2293
|
+
repeats.set(cacheKey, (repeats.get(cacheKey) ?? 0) + 1);
|
|
2294
|
+
}
|
|
2295
|
+
continue;
|
|
2296
|
+
}
|
|
2297
|
+
if (
|
|
2298
|
+
part.content.startsWith(
|
|
2299
|
+
resurfacedDuplicateReadOnlyToolResultPrefix(call.name),
|
|
2300
|
+
)
|
|
2301
|
+
) {
|
|
2302
|
+
if (reusableReadKeys.has(cacheKey)) repeats.set(cacheKey, 0);
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
if (isReusableReadOnlyToolResult(part)) {
|
|
2306
|
+
reusableReadKeys.add(cacheKey);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
return repeats;
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2200
2314
|
function isReusableReadOnlyToolResult(part: EngineToolResultPart): boolean {
|
|
2201
2315
|
if (part.isError) return false;
|
|
2202
2316
|
const lower = part.content.trim().toLowerCase();
|
|
2203
2317
|
if (!lower) return false;
|
|
2204
2318
|
return !(
|
|
2319
|
+
lower.startsWith("skipped duplicate read-only call to ") ||
|
|
2205
2320
|
lower.startsWith("invalid action parameters for ") ||
|
|
2206
2321
|
lower.startsWith("error running ") ||
|
|
2207
2322
|
lower.includes("run aborted") ||
|
|
@@ -2211,6 +2326,56 @@ function isReusableReadOnlyToolResult(part: EngineToolResultPart): boolean {
|
|
|
2211
2326
|
);
|
|
2212
2327
|
}
|
|
2213
2328
|
|
|
2329
|
+
/**
|
|
2330
|
+
* Whether a cached read-only tool result is still something the model can
|
|
2331
|
+
* actually see in `contextMessages` — the trimmed/summarized view the engine
|
|
2332
|
+
* is streamed (NOT the raw, ever-growing `messages` array the cache is keyed
|
|
2333
|
+
* off of). Context-xray `evict` drops tool-result parts entirely, `summarize`
|
|
2334
|
+
* replaces their content with a placeholder, and observational-memory
|
|
2335
|
+
* trimming drops whole older messages once active. When the cached result
|
|
2336
|
+
* has fallen out of that view, re-serving "use the previous result" is not
|
|
2337
|
+
* actionable — the model has nothing to point back to.
|
|
2338
|
+
*
|
|
2339
|
+
* A visible result must belong to the same tool name + normalized input and
|
|
2340
|
+
* contain either the exact cached body or the exact wrapper used when that
|
|
2341
|
+
* body was re-served after trimming. Matching only by a content suffix is too
|
|
2342
|
+
* loose: short results such as "ok" can also end unrelated tool output.
|
|
2343
|
+
*/
|
|
2344
|
+
export function isCachedToolResultVisibleInContext(
|
|
2345
|
+
contextMessages: EngineMessage[],
|
|
2346
|
+
toolCall: { name: string; input: unknown },
|
|
2347
|
+
cachedResult: string,
|
|
2348
|
+
): boolean {
|
|
2349
|
+
if (cachedResult.length === 0) return true;
|
|
2350
|
+
const cacheKey = toolCallCacheKey(toolCall.name, toolCall.input);
|
|
2351
|
+
const matchingToolCallIds = new Set<string>();
|
|
2352
|
+
for (const message of contextMessages) {
|
|
2353
|
+
if (message.role !== "assistant") continue;
|
|
2354
|
+
for (const part of message.content) {
|
|
2355
|
+
if (part.type !== "tool-call") continue;
|
|
2356
|
+
if (toolCallCacheKey(part.name, part.input) === cacheKey) {
|
|
2357
|
+
matchingToolCallIds.add(part.id);
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
const resurfacedResult = resurfacedDuplicateReadOnlyToolResult(
|
|
2363
|
+
toolCall.name,
|
|
2364
|
+
cachedResult,
|
|
2365
|
+
);
|
|
2366
|
+
for (const message of contextMessages) {
|
|
2367
|
+
if (message.role !== "user") continue;
|
|
2368
|
+
for (const part of message.content) {
|
|
2369
|
+
if (part.type !== "tool-result") continue;
|
|
2370
|
+
if (!matchingToolCallIds.has(part.toolCallId)) continue;
|
|
2371
|
+
if (part.content === cachedResult || part.content === resurfacedResult) {
|
|
2372
|
+
return true;
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
return false;
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2214
2379
|
/**
|
|
2215
2380
|
* Counts how many times each write (non-read-only) tool call was interrupted
|
|
2216
2381
|
* before returning a result in the continuation history. When a connection
|
|
@@ -3106,7 +3271,10 @@ export async function runAgentLoop(opts: {
|
|
|
3106
3271
|
messages,
|
|
3107
3272
|
actions,
|
|
3108
3273
|
);
|
|
3109
|
-
const duplicateReadOnlyToolCalls =
|
|
3274
|
+
const duplicateReadOnlyToolCalls = seedDuplicateReadOnlyToolCallsFromHistory(
|
|
3275
|
+
messages,
|
|
3276
|
+
actions,
|
|
3277
|
+
);
|
|
3110
3278
|
const writeToolInterruptions = seedWriteToolInterruptionsFromHistory(
|
|
3111
3279
|
messages,
|
|
3112
3280
|
actions,
|
|
@@ -4339,18 +4507,49 @@ export async function runAgentLoop(opts: {
|
|
|
4339
4507
|
};
|
|
4340
4508
|
}
|
|
4341
4509
|
|
|
4510
|
+
// dedupe: false opts a read-only tool out of the guard entirely — the
|
|
4511
|
+
// cacheKey stays null so it never gets skipped-as-duplicate and never
|
|
4512
|
+
// populates the cache (see the success handler below, which also
|
|
4513
|
+
// leaves dedupe:false results uncached and un-cleared).
|
|
4342
4514
|
const cacheKey =
|
|
4343
|
-
actionEntry.readOnly === true
|
|
4515
|
+
actionEntry.readOnly === true && actionEntry.dedupe !== false
|
|
4344
4516
|
? toolCallCacheKey(toolCall.name, toolCall.input)
|
|
4345
4517
|
: null;
|
|
4346
4518
|
if (cacheKey && readOnlyToolResultCache.has(cacheKey)) {
|
|
4347
|
-
const repeats = (duplicateReadOnlyToolCalls.get(cacheKey) ?? 0) + 1;
|
|
4348
|
-
duplicateReadOnlyToolCalls.set(cacheKey, repeats);
|
|
4349
4519
|
const previousResult = readOnlyToolResultCache.get(cacheKey) ?? "";
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4520
|
+
// `contextMessages` (not `messages`) is what the model actually sees
|
|
4521
|
+
// this iteration — context-xray eviction/summarization and
|
|
4522
|
+
// observational-memory trimming can drop the earlier result from
|
|
4523
|
+
// view even though it's still cached here. Only strike-count the
|
|
4524
|
+
// repeat when the model could have looked back and found it itself.
|
|
4525
|
+
const visible = isCachedToolResultVisibleInContext(
|
|
4526
|
+
contextMessages,
|
|
4527
|
+
toolCall,
|
|
4528
|
+
previousResult,
|
|
4529
|
+
);
|
|
4530
|
+
let result: string;
|
|
4531
|
+
if (visible) {
|
|
4532
|
+
const repeats = (duplicateReadOnlyToolCalls.get(cacheKey) ?? 0) + 1;
|
|
4533
|
+
duplicateReadOnlyToolCalls.set(cacheKey, repeats);
|
|
4534
|
+
result = visibleDuplicateReadOnlyToolResult(toolCall.name);
|
|
4535
|
+
if (repeats >= 3) {
|
|
4536
|
+
requestedActionStop ??= {
|
|
4537
|
+
message:
|
|
4538
|
+
"I stopped because the agent kept asking for the same read-only context it already had. Please send the request again if you want me to retry from a fresh turn.",
|
|
4539
|
+
errorCode: "duplicate_read_only_tool",
|
|
4540
|
+
};
|
|
4541
|
+
}
|
|
4542
|
+
} else {
|
|
4543
|
+
// The earlier result was trimmed out of the model's visible
|
|
4544
|
+
// context — this isn't a repetitive loop, the model legitimately
|
|
4545
|
+
// can't see the answer anymore. Re-serve it in full and don't
|
|
4546
|
+
// count a strike.
|
|
4547
|
+
duplicateReadOnlyToolCalls.set(cacheKey, 0);
|
|
4548
|
+
result = resurfacedDuplicateReadOnlyToolResult(
|
|
4549
|
+
toolCall.name,
|
|
4550
|
+
previousResult,
|
|
4551
|
+
);
|
|
4552
|
+
}
|
|
4354
4553
|
send({
|
|
4355
4554
|
type: "tool_done",
|
|
4356
4555
|
id: toolCall.id,
|
|
@@ -4360,13 +4559,6 @@ export async function runAgentLoop(opts: {
|
|
|
4360
4559
|
completedSideEffect: false,
|
|
4361
4560
|
});
|
|
4362
4561
|
recordToolResult(result, false);
|
|
4363
|
-
if (repeats >= 3) {
|
|
4364
|
-
requestedActionStop ??= {
|
|
4365
|
-
message:
|
|
4366
|
-
"I stopped because the agent kept asking for the same read-only context it already had. Please send the request again if you want me to retry from a fresh turn.",
|
|
4367
|
-
errorCode: "duplicate_read_only_tool",
|
|
4368
|
-
};
|
|
4369
|
-
}
|
|
4370
4562
|
return {
|
|
4371
4563
|
type: "tool-result" as const,
|
|
4372
4564
|
toolCallId: toolCall.id,
|
|
@@ -4638,7 +4830,11 @@ export async function runAgentLoop(opts: {
|
|
|
4638
4830
|
if (!isError) {
|
|
4639
4831
|
if (cacheKey) {
|
|
4640
4832
|
readOnlyToolResultCache.set(cacheKey, result);
|
|
4641
|
-
} else {
|
|
4833
|
+
} else if (actionEntry.readOnly !== true) {
|
|
4834
|
+
// A genuine write invalidates all cached reads. A dedupe:false
|
|
4835
|
+
// read-only tool also has a null cacheKey (see above) but must NOT
|
|
4836
|
+
// clear the cache — it isn't a write and other tools' cached reads
|
|
4837
|
+
// are still valid.
|
|
4642
4838
|
readOnlyToolResultCache.clear();
|
|
4643
4839
|
duplicateReadOnlyToolCalls.clear();
|
|
4644
4840
|
}
|
|
@@ -139,7 +139,7 @@ export function createRunCodeEntry(
|
|
|
139
139
|
" - `workspaceList(prefix?)` — list workspace files, returns [{ path, sizeBytes, contentType, updatedAt }].",
|
|
140
140
|
"Print results with `console.log()`; only stdout+stderr are returned.",
|
|
141
141
|
"Timeout defaults to 120 s (max 600 s). Output is truncated to 50 000 chars by default (max 200 000).",
|
|
142
|
-
'For LONG compute — big cross-source joins, corpus-wide sweeps, scripts that could exceed ~30 s, or anything at risk of dying with the current chat run — pass `background: true`. That enqueues a durable execution and returns `{ executionId, status: "queued" }` immediately; the code runs out-of-band with a generous budget (default 10 min) and its result survives run timeouts. Continue other work, then poll
|
|
142
|
+
'For LONG compute — big cross-source joins, corpus-wide sweeps, scripts that could exceed ~30 s, or anything at risk of dying with the current chat run — pass `background: true`. That enqueues a durable execution and returns `{ executionId, status: "queued" }` immediately; the code runs out-of-band with a generous budget (default 10 min) and its result survives run timeouts. Continue other work, then poll with `get-code-execution` when that dedicated tool is available; legacy hosts can call run-code again with just `{ executionId }`. Keep quick scripts in the default foreground mode.',
|
|
143
143
|
].join(" "),
|
|
144
144
|
parameters: {
|
|
145
145
|
type: "object",
|
|
@@ -160,12 +160,12 @@ export function createRunCodeEntry(
|
|
|
160
160
|
background: {
|
|
161
161
|
type: "boolean",
|
|
162
162
|
description:
|
|
163
|
-
'Run as a durable background execution: returns { executionId, status: "queued" } immediately and the code executes out-of-band, surviving chat-run timeouts. Use for long compute (large joins, multi-page provider sweeps, heavy analysis). Poll with
|
|
163
|
+
'Run as a durable background execution: returns { executionId, status: "queued" } immediately and the code executes out-of-band, surviving chat-run timeouts. Use for long compute (large joins, multi-page provider sweeps, heavy analysis). Poll with get-code-execution when available.',
|
|
164
164
|
},
|
|
165
165
|
executionId: {
|
|
166
166
|
type: "string",
|
|
167
167
|
description:
|
|
168
|
-
"
|
|
168
|
+
"Legacy polling fallback for hosts without get-code-execution: pass the executionId alone (no code) to get status and output.",
|
|
169
169
|
},
|
|
170
170
|
},
|
|
171
171
|
required: [],
|
|
@@ -582,12 +582,18 @@ function formatTerminalSandboxExecution(row: SandboxExecutionRow): string {
|
|
|
582
582
|
/**
|
|
583
583
|
* Standalone, access-scoped poll tool for background executions. Behaviorally
|
|
584
584
|
* identical to calling `run-code` with only `executionId`; hosts that register
|
|
585
|
-
* it as `get-code-execution` give the model a dedicated read tool (and
|
|
586
|
-
* enqueue guidance automatically points at it when present in the
|
|
585
|
+
* it as `get-code-execution` give the model a dedicated volatile read tool (and
|
|
586
|
+
* the enqueue guidance automatically points at it when present in the
|
|
587
|
+
* registry). Keep the opt-out here rather than on `run-code`: repeated normal
|
|
588
|
+
* run-code calls may execute writes or outbound requests and must retain the
|
|
589
|
+
* agent loop's default duplicate-call protection.
|
|
587
590
|
*/
|
|
588
591
|
export function createGetCodeExecutionEntry(): ActionEntry {
|
|
589
592
|
return {
|
|
590
593
|
readOnly: true,
|
|
594
|
+
// Polling with an identical executionId is the intended usage — the
|
|
595
|
+
// status changes over time, so this must not be deduped.
|
|
596
|
+
dedupe: false,
|
|
591
597
|
tool: {
|
|
592
598
|
description:
|
|
593
599
|
"Check a background run-code execution: returns its status (queued | running | succeeded | failed | timed_out) and, once finished, its stdout/stderr output. Executions are scoped to the user who started them. While one is queued or running, continue other useful work and poll every ~15-30 seconds instead of busy-waiting.",
|
|
@@ -44,6 +44,15 @@ import {
|
|
|
44
44
|
type ExtensionRow,
|
|
45
45
|
} from "./store.js";
|
|
46
46
|
|
|
47
|
+
// A 200k extension body containing JSON-sensitive HTML/JS characters (quotes,
|
|
48
|
+
// backslashes, and newlines) expands to about 400k characters when pretty-JSON
|
|
49
|
+
// serialized by the agent loop. A history detail can carry the current and
|
|
50
|
+
// previous bodies plus both bodies again in its line diff (about 1.6M chars in
|
|
51
|
+
// the same worst-common-case fixture). These caps add roughly 25% headroom for
|
|
52
|
+
// the surrounding metadata and indentation while still bounding tool context.
|
|
53
|
+
const GET_EXTENSION_MAX_RESULT_CHARS = 500_000;
|
|
54
|
+
const GET_EXTENSION_HISTORY_MAX_RESULT_CHARS = 2_000_000;
|
|
55
|
+
|
|
47
56
|
export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
48
57
|
return {
|
|
49
58
|
"list-extensions": {
|
|
@@ -185,6 +194,9 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
|
185
194
|
),
|
|
186
195
|
};
|
|
187
196
|
},
|
|
197
|
+
// Result is JSON including the full Alpine content; account for JSON
|
|
198
|
+
// escaping and envelope metadata instead of matching the source cap.
|
|
199
|
+
maxResultChars: GET_EXTENSION_MAX_RESULT_CHARS,
|
|
188
200
|
readOnly: true,
|
|
189
201
|
},
|
|
190
202
|
|
|
@@ -276,6 +288,9 @@ export function createExtensionActionEntries(): Record<string, ActionEntry> {
|
|
|
276
288
|
),
|
|
277
289
|
};
|
|
278
290
|
},
|
|
291
|
+
// With includeContent, history can contain current + previous source and
|
|
292
|
+
// repeat both in the diff, so it needs more headroom than get-extension.
|
|
293
|
+
maxResultChars: GET_EXTENSION_HISTORY_MAX_RESULT_CHARS,
|
|
279
294
|
readOnly: true,
|
|
280
295
|
},
|
|
281
296
|
|
|
@@ -251,17 +251,27 @@ export async function createSsrfSafeDispatcher(): Promise<unknown | null> {
|
|
|
251
251
|
* `httpsOnly` extends the per-hop validation to the URL scheme: redirects are
|
|
252
252
|
* followed only to `https:` targets, so an HTTPS-only caller cannot be
|
|
253
253
|
* downgraded to plain HTTP by a 30x from the (untrusted) origin.
|
|
254
|
+
*
|
|
255
|
+
* `assertUrlAllowed` lets callers layer a stricter destination policy (for
|
|
256
|
+
* example, a credential's origin allowlist) on top of the SSRF checks. It runs
|
|
257
|
+
* before the initial request and before every redirect hop, so sensitive
|
|
258
|
+
* headers and bodies are never forwarded to a destination the caller rejects.
|
|
254
259
|
*/
|
|
255
260
|
export async function ssrfSafeFetch(
|
|
256
261
|
url: string,
|
|
257
262
|
init: RequestInit = {},
|
|
258
|
-
options: {
|
|
263
|
+
options: {
|
|
264
|
+
maxRedirects?: number;
|
|
265
|
+
httpsOnly?: boolean;
|
|
266
|
+
assertUrlAllowed?: (url: string) => void | Promise<void>;
|
|
267
|
+
} = {},
|
|
259
268
|
): Promise<Response> {
|
|
260
269
|
const maxRedirects = options.maxRedirects ?? 3;
|
|
261
270
|
const dispatcher = (await createSsrfSafeDispatcher()) ?? undefined;
|
|
262
271
|
|
|
263
272
|
let currentUrl = url;
|
|
264
273
|
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
274
|
+
await options.assertUrlAllowed?.(currentUrl);
|
|
265
275
|
if (options.httpsOnly && new URL(currentUrl).protocol !== "https:") {
|
|
266
276
|
throw new Error(
|
|
267
277
|
`SSRF blocked: refusing to fetch non-HTTPS address (${currentUrl})`,
|
|
@@ -29,9 +29,10 @@
|
|
|
29
29
|
|
|
30
30
|
import { ssrfSafeFetch } from "../extensions/url-safety.js";
|
|
31
31
|
import {
|
|
32
|
+
getKeyAllowlist,
|
|
33
|
+
getResolvedKeyAllowlist,
|
|
32
34
|
resolveKeyReferencesWithRequestScopes,
|
|
33
35
|
validateUrlAllowlist,
|
|
34
|
-
getKeyAllowlist,
|
|
35
36
|
} from "../secrets/substitution.js";
|
|
36
37
|
import { sendEmail } from "../server/email.js";
|
|
37
38
|
import { registerNotificationChannel } from "./registry.js";
|
|
@@ -72,7 +73,7 @@ function createWebhookChannel(
|
|
|
72
73
|
// No-op when neither a per-notification nor workspace URL is set —
|
|
73
74
|
// mirrors email's empty-recipients behavior so notify-all stays quiet.
|
|
74
75
|
if (!urlTemplate) return false;
|
|
75
|
-
const { url, headers } = await resolveWebhookRequest(
|
|
76
|
+
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
|
|
76
77
|
urlTemplate,
|
|
77
78
|
overrideUrlTemplate ? undefined : authTemplate,
|
|
78
79
|
meta.owner,
|
|
@@ -92,7 +93,7 @@ function createWebhookChannel(
|
|
|
92
93
|
emittedAt: new Date().toISOString(),
|
|
93
94
|
}),
|
|
94
95
|
},
|
|
95
|
-
{ maxRedirects: 3 },
|
|
96
|
+
{ maxRedirects: 3, assertUrlAllowed },
|
|
96
97
|
);
|
|
97
98
|
if (!res.ok) {
|
|
98
99
|
throw new Error(
|
|
@@ -121,7 +122,7 @@ function createSlackWebhookChannel(
|
|
|
121
122
|
metadataString(input.metadata, "slackWebhookUrl") ??
|
|
122
123
|
envUrlTemplate?.trim();
|
|
123
124
|
if (!urlTemplate) return false;
|
|
124
|
-
const { url, headers } = await resolveWebhookRequest(
|
|
125
|
+
const { url, headers, assertUrlAllowed } = await resolveWebhookRequest(
|
|
125
126
|
urlTemplate,
|
|
126
127
|
overrideUrlTemplate ? undefined : authTemplate,
|
|
127
128
|
meta.owner,
|
|
@@ -165,7 +166,7 @@ function createSlackWebhookChannel(
|
|
|
165
166
|
],
|
|
166
167
|
}),
|
|
167
168
|
},
|
|
168
|
-
{ maxRedirects: 3 },
|
|
169
|
+
{ maxRedirects: 3, assertUrlAllowed },
|
|
169
170
|
);
|
|
170
171
|
if (!res.ok) {
|
|
171
172
|
throw new Error(
|
|
@@ -214,7 +215,11 @@ async function resolveWebhookRequest(
|
|
|
214
215
|
authTemplate: string | undefined,
|
|
215
216
|
owner: string,
|
|
216
217
|
label: string,
|
|
217
|
-
): Promise<{
|
|
218
|
+
): Promise<{
|
|
219
|
+
url: string;
|
|
220
|
+
headers: Record<string, string>;
|
|
221
|
+
assertUrlAllowed: (url: string) => void;
|
|
222
|
+
}> {
|
|
218
223
|
// Resolve `${keys.NAME}` references through the same request-scope
|
|
219
224
|
// cascade already used by extension fetches (extensions/routes.ts) and
|
|
220
225
|
// automation connector headers (automation/index.ts): user scope first
|
|
@@ -229,46 +234,72 @@ async function resolveWebhookRequest(
|
|
|
229
234
|
// strict superset of the previous user-scope-only behavior.
|
|
230
235
|
// Missing keys throw — the error surfaces in logs and the channel is marked
|
|
231
236
|
// un-delivered, but other channels still run.
|
|
232
|
-
const
|
|
237
|
+
const urlResult = await resolveKeyReferencesWithRequestScopes(
|
|
233
238
|
urlTemplate,
|
|
234
239
|
owner,
|
|
235
240
|
);
|
|
241
|
+
const url = urlResult.resolved;
|
|
236
242
|
const headers: Record<string, string> = {
|
|
237
243
|
"Content-Type": "application/json",
|
|
238
244
|
};
|
|
245
|
+
let authResult:
|
|
246
|
+
| Awaited<ReturnType<typeof resolveKeyReferencesWithRequestScopes>>
|
|
247
|
+
| undefined;
|
|
239
248
|
if (authTemplate) {
|
|
240
|
-
|
|
249
|
+
authResult = await resolveKeyReferencesWithRequestScopes(
|
|
241
250
|
authTemplate,
|
|
242
251
|
owner,
|
|
243
252
|
);
|
|
244
|
-
headers.Authorization =
|
|
253
|
+
headers.Authorization = authResult.resolved;
|
|
245
254
|
}
|
|
246
255
|
|
|
247
256
|
// If the user set an allowlist on a referenced key, enforce it here —
|
|
248
257
|
// origin-level check, same rule the automations fetch-tool applies.
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
)
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
keyNames.map((name) => getKeyAllowlist(name, "user", owner)),
|
|
263
|
-
);
|
|
264
|
-
keyNames.forEach((name, i) => {
|
|
265
|
-
if (!validateUrlAllowlist(url, allowlists[i])) {
|
|
266
|
-
throw new Error(
|
|
267
|
-
`[notifications] ${label} URL ${new URL(url).origin} is not in the allowlist for key "${name}"`,
|
|
258
|
+
// Validate URL and Authorization keys independently so an allowlisted auth
|
|
259
|
+
// token cannot be sent to a metadata-provided destination. Keep scope in the
|
|
260
|
+
// dedupe key because the same key name can resolve at a different scope if a
|
|
261
|
+
// credential changes between the two substitutions.
|
|
262
|
+
type ResolvedKey = NonNullable<typeof urlResult.resolvedKeys>[number];
|
|
263
|
+
const keyUsages = new Map<
|
|
264
|
+
string,
|
|
265
|
+
{ name: string; resolvedKey?: ResolvedKey }
|
|
266
|
+
>();
|
|
267
|
+
for (const result of authResult ? [urlResult, authResult] : [urlResult]) {
|
|
268
|
+
for (const name of result.usedKeys) {
|
|
269
|
+
const resolved = (result.resolvedKeys ?? []).filter(
|
|
270
|
+
(ref) => ref.name === name,
|
|
268
271
|
);
|
|
272
|
+
if (resolved.length === 0) {
|
|
273
|
+
keyUsages.set(`user:${owner}:${name}`, { name });
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
for (const resolvedKey of resolved) {
|
|
277
|
+
keyUsages.set(`${resolvedKey.scope}:${resolvedKey.scopeId}:${name}`, {
|
|
278
|
+
name,
|
|
279
|
+
resolvedKey,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
269
282
|
}
|
|
270
|
-
}
|
|
271
|
-
|
|
283
|
+
}
|
|
284
|
+
const usages = Array.from(keyUsages.values());
|
|
285
|
+
const allowlists = await Promise.all(
|
|
286
|
+
usages.map(({ name, resolvedKey }) =>
|
|
287
|
+
resolvedKey
|
|
288
|
+
? getResolvedKeyAllowlist(resolvedKey)
|
|
289
|
+
: getKeyAllowlist(name, "user", owner),
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
const assertUrlAllowed = (candidateUrl: string): void => {
|
|
293
|
+
usages.forEach(({ name }, i) => {
|
|
294
|
+
if (!validateUrlAllowlist(candidateUrl, allowlists[i])) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`[notifications] ${label} URL ${new URL(candidateUrl).origin} is not in the allowlist for key "${name}"`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
assertUrlAllowed(url);
|
|
302
|
+
return { url, headers, assertUrlAllowed };
|
|
272
303
|
}
|
|
273
304
|
|
|
274
305
|
function metadataString(
|