Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
7c7a650
feat: Add opt-in tool result caps, coding tool selection and stop hooks
berry-13 Oct 16, 2026
16dc16c
fix: Close cap, verification and allowlist gaps in local tool options
berry-13 Oct 16, 2026
d9fe2cf
fix: Tighten test run detection and apply read cap to explicit limits
berry-13 Oct 16, 2026
3955f4e
fix: Detect test runs from the last command of a shell line
berry-13 Oct 16, 2026
10be9ce
fix: Keep redirections, listing modes and edit-then-test calls correct
berry-13 Oct 16, 2026
8e70bb7
fix: Ignore failed edits, dry runs and non-running test modes
berry-13 Oct 16, 2026
1af99ce
fix: Skip conditional runner calls and more listing modes
berry-13 Oct 16, 2026
1429873
fix: Respect concurrent tool batches and execution world in local opt…
berry-13 Oct 16, 2026
6010b01
fix: Keep failing siblings and background edits from verifying a change
berry-13 Oct 16, 2026
f508357
fix: Handle trailing terminators, equals flags and conditional groups
berry-13 Oct 16, 2026
da47135
fix: Skip background test runs and runner inspection modes
berry-13 Oct 16, 2026
f13fb18
fix: Recognize sed --in-place and lines that can exit before tests
berry-13 Oct 16, 2026
abde6e1
fix: Scope verification to the current user turn and fail signalled t…
berry-13 Oct 16, 2026
ecc2343
fix: Recognize pytest --collectonly and ctest --show-only as non-running
berry-13 Oct 16, 2026
87d34e7
fix: Recognize listing flags before a closing parenthesis
berry-13 Oct 16, 2026
905aec8
fix: Skip hook context when finding the user turn and honor the run b…
berry-13 Oct 16, 2026
c3b59e8
fix: Ignore commands inside unquoted shell comments
berry-13 Oct 16, 2026
323cbea
fix: Keep sticky test patterns anchored and ignore quoted edit flags
berry-13 Oct 16, 2026
4bf5275
fix: Do not count runners inside command substitutions
berry-13 Oct 16, 2026
2107822
fix: Require complete runner tokens and skip git apply report modes
berry-13 Oct 16, 2026
904f472
fix: Read listing flags through shell quotes
berry-13 Oct 16, 2026
80c0d5b
fix: Match vitest list only as a subcommand and skip Maven test-skip …
berry-13 Oct 16, 2026
7f0dee4
fix: Keep exit status lines when capping bash output
berry-13 Oct 16, 2026
fa38a2a
refactor: Move the stop hooks to a separate change
berry-13 Oct 16, 2026
a568c4b
fix: Apply the local allowlist and intent opt-out on every tool path
berry-13 Oct 16, 2026
b34b72a
fix: Drop spill files, keep grep skip notices and filter tool definit…
berry-13 Oct 16, 2026
c45bfe6
fix: Keep the tail of capped output when its only newline ends it
berry-13 Oct 16, 2026
67d3276
fix: Enforce the local allowlist on graph tools and registry runners
berry-13 Oct 16, 2026
657291d
fix: Bound capped grep lines by small result caps
berry-13 Oct 16, 2026
b2dc965
fix: Build only allowlisted coding tools and cap grep errors
berry-13 Oct 16, 2026
d9304c9
refactor: Move toolIntentLabels to a separate change
berry-13 Oct 16, 2026
1b4712f
fix: Filter provider-native tool schemas and cap fallback grep errors
berry-13 Oct 16, 2026
fff1ce3
fix: Ignore cached tool schema totals once the allowlist removes tools
berry-13 Oct 16, 2026
a405e2c
fix: Keep cached schema totals for native schemas and cap grep matche…
berry-13 Oct 16, 2026
0127853
fix: Read Bedrock toolSpec names in the coding tool allowlist
berry-13 Oct 16, 2026
df37e12
fix: Pass the run tool execution config into manual compaction
berry-13 Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions src/agents/AgentContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import {
isProgrammaticRunnerAutoBound,
isProgrammaticRunnerResolvedDirectly,
filterToolsByLocalAllowlist,
resolveLocalImplementationNames,
resolveLocalToolRegistry,
} from '@/tools/local/resolveLocalExecutionTools';
Expand Down Expand Up @@ -183,7 +184,11 @@ export class AgentContext {
* subagent) into this same array and must not mutate the host's input.
*/
if (graphTools && graphTools.length > 0) {
agentContext.graphTools = [...graphTools];
agentContext.graphTools = [
...((filterToolsByLocalAllowlist(graphTools, toolExecution) as
| t.GenericTool[]
| undefined) ?? []),
] as t.GraphTools;
}

if (initialSummary?.text != null && initialSummary.text !== '') {
Expand All @@ -200,7 +205,28 @@ export class AgentContext {
agentContext.baseIndexTokenCountMap = { ...tokenMap };
agentContext.indexTokenCountMap = tokenMap;

if (toolSchemaTokens != null && toolSchemaTokens > 0) {
/** A cached total counts the host's unfiltered tools. Once the local
* allowlist removed some, recount, but only when the recount can
* measure every retained tool (it reads `.schema`); otherwise the
* larger cached total is the safer budget. */
const allowlistRemovedTools =
(tools?.length ?? 0) !== (agentContext.tools?.length ?? 0) ||
(toolDefinitions?.length ?? 0) !==
(agentContext.toolDefinitions?.length ?? 0) ||
(graphTools?.length ?? 0) !== (agentContext.graphTools?.length ?? 0);
const recountMeasuresAll = [
...((agentContext.tools as unknown[] | undefined) ?? []),
...((agentContext.graphTools as unknown[] | undefined) ?? []),
].every(
(tool) =>
tool != null &&
typeof (tool as { schema?: unknown }).schema === 'object'
);
if (
toolSchemaTokens != null &&
toolSchemaTokens > 0 &&
!(allowlistRemovedTools && recountMeasuresAll)
) {
/** Use pre-computed (cached) tool schema tokens — skip calculateInstructionTokens */
agentContext.toolSchemaTokens = toolSchemaTokens;
agentContext.tokenCalculationPromise = Promise.resolve();
Expand Down Expand Up @@ -537,14 +563,17 @@ export class AgentContext {
tokenCounter != null && isTokenCounterCacheCompatible(tokenCounter)
? createExactTokenCountCache(tokenCounter)
: undefined;
this.tools = tools;
this.tools = filterToolsByLocalAllowlist(tools, toolExecution);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Filter event definitions through the local allowlist

When an event-driven host provides an omitted bundle tool in toolDefinitions (for example, write_file with allowed_callers: ['direct', 'code_execution']) while allowlisting read_file and run_tools_with_code, this line filters only tools; getDirectProgrammaticCapabilityProjection() still treats every event definition as an implementation and tells the model that write_file is available, while resolveLocalExecutionTools() removes it from ToolNode's map. Fresh evidence beyond the fixed explicit-tools path is that toolDefinitions is still stored unfiltered later in this constructor; apply the same selected-name set to event definitions and their prompt projections.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 26b96e9. AgentContext now passes toolDefinitions through the same filterToolsByLocalAllowlist as tools, so event-driven definitions and their prompt projections see only allowlisted coding tools. Regression test: 'filters event-driven tool definitions with the local allowlist'.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the allowlist to graph-managed tools

When an omitted bundle-named tool is supplied through AgentInputs.graphTools (for example run_tools_with_code while only read_file is allowlisted), this filters only tools; fromConfig later copies graphTools unchanged at AgentContext.ts:186-187. The omitted schema is therefore counted and treated as a bound programmatic runner by AgentContext.ts:741-742,1413-1417, even though Graph.getPreparedToolsForBinding() finally removes it at Graph.ts:3202-3207, so low-budget runs account for a schema that is never sent and the prompt can advertise an unavailable runner. Fresh evidence beyond the earlier explicit-tools fix is this post-constructor, unfiltered graphTools assignment; apply the same filter before prompt projections.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 79b2e14. AgentContext.fromConfig now passes graphTools through filterToolsByLocalAllowlist, so tools, toolDefinitions and graphTools all reach prompt projections already filtered. Regression test: 'drops graphTools outside the local coding allowlist'.

Comment thread
berry-13 marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate cached schema totals when filtering tools

When a caller reuses a nonzero AgentInputs.toolSchemaTokens cache produced for the unfiltered tool set and enables local.codingToolNames, this assignment removes schemas but fromConfig() still accepts the old aggregate and skips calculateInstructionTokens() at lines 208-212. Tight-context runs can therefore compact or reject based on schemas that are never bound; the cached total needs to be ignored or keyed to the effective allowlisted set.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 010f316. fromConfig now uses a cached toolSchemaTokens total only when the local allowlist removed none of the host's tools, toolDefinitions or graphTools; otherwise it recalculates. Regression test: 'ignores a cached toolSchemaTokens total once the local allowlist removes tools'.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate the allowlist into manual session compaction

When AgentSession.compact() is used for a run whose local.codingToolNames omits a host-supplied bundle tool, src/session/AgentSession.ts:1716-1724 invokes AgentContext.fromConfig() without this.runConfig.toolExecution, so this new filter sees undefined. The manual summarizer consequently retains and counts the omitted schema and binds it at src/summarization/node.ts:684-702, unlike the normal graph path; this can waste a tight context budget or make summarization emit an unavailable tool call. Pass the run's execution config into the compaction context as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in df37e12. AgentSession.compact() now passes this.runConfig.toolExecution to AgentContext.fromConfig, so the manual summarizer sees the same allowlisted tools as the graph path. Regression test: 'builds the manual compaction context with the run tool execution config'.

this.toolMap = toolMap;
this.toolRegistry = resolveLocalToolRegistry({
toolRegistry,
toolExecution,
});
this.toolExecution = toolExecution;
this.toolDefinitions = toolDefinitions;
this.toolDefinitions = filterToolsByLocalAllowlist(
toolDefinitions as t.GraphTools | undefined,
toolExecution
) as t.LCTool[] | undefined;
this.instructions = instructions;
this.additionalInstructions = additionalInstructions;
if (reasoningKey) {
Expand Down
60 changes: 60 additions & 0 deletions src/agents/__tests__/AgentContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ describe('AgentContext', () => {
expect(hostGraphTools).toHaveLength(1);
});

it('drops graphTools outside the local coding allowlist', () => {
const runner = createMockTool('run_tools_with_code');
const askTool = createMockTool('ask_user_question');
const ctx = createBasicContext({
agentConfig: { graphTools: [runner, askTool] },
toolExecution: {
engine: 'local',
local: { codingToolNames: ['read_file'] },
},
});
expect(
(ctx.graphTools as t.GenericTool[]).map((tool) => tool.name)
).toEqual(['ask_user_question']);
});

it('leaves graphTools undefined when the host supplies none (or an empty list)', () => {
expect(createBasicContext().graphTools).toBeUndefined();
expect(
Expand Down Expand Up @@ -1794,6 +1809,51 @@ describe('AgentContext', () => {
expect(ctx.instructionTokens).toBeGreaterThan(initialTokens);
});

it('ignores a cached toolSchemaTokens total once the local allowlist removes tools', async () => {
const defs: t.LCTool[] = ['read_file', 'bash_tool'].map((name) => ({
name,
description: name,
parameters: { type: 'object', properties: {} },
}));
const toolExecution: t.ToolExecutionConfig = {
engine: 'local',
local: { codingToolNames: ['read_file'] },
};
const filtered = createBasicContext({
agentConfig: { toolDefinitions: defs, toolSchemaTokens: 99999 },
tokenCounter: mockTokenCounter,
toolExecution,
});
const unfiltered = createBasicContext({
agentConfig: { toolDefinitions: defs, toolSchemaTokens: 99999 },
tokenCounter: mockTokenCounter,
});
await filtered.tokenCalculationPromise;
await unfiltered.tokenCalculationPromise;
expect(filtered.toolSchemaTokens).not.toBe(99999);
expect(unfiltered.toolSchemaTokens).toBe(99999);
});

it('keeps the cached total when a retained tool is a native schema the recount cannot measure', async () => {
const nativeSearch = {
type: 'function',
function: { name: 'web_search', parameters: {} },
} as unknown as t.GenericTool;
const ctx = createBasicContext({
agentConfig: {
tools: [createMockTool('bash_tool'), nativeSearch],
toolSchemaTokens: 99999,
},
tokenCounter: mockTokenCounter,
toolExecution: {
engine: 'local',
local: { codingToolNames: ['read_file'] },
},
});
await ctx.tokenCalculationPromise;
expect(ctx.toolSchemaTokens).toBe(99999);
});

it('excludes deferred-undiscovered toolDefinitions from toolSchemaTokens', async () => {
const activeDef: t.LCTool = {
name: 'active_tool',
Expand Down
4 changes: 3 additions & 1 deletion src/session/AgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1720,7 +1720,9 @@ export class AgentSession {
options.retainRecentTurns,
options.instructions
),
this.runConfig.tokenCounter
this.runConfig.tokenCounter,
undefined,
this.runConfig.toolExecution
);
const graph = createManualCompactGraph({
runId: compactRunId,
Expand Down
27 changes: 27 additions & 0 deletions src/session/__tests__/JsonlSessionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { toJsonValue } from '@/session/messageSerialization';
import * as providers from '@/llm/providers';
import { GraphEvents } from '@/common';
import { Run } from '@/run';
import { AgentContext } from '@/agents/AgentContext';

type MockRun = {
processStream: jest.MockedFunction<Run<t.IState>['processStream']>;
Expand Down Expand Up @@ -178,6 +179,32 @@ describe('JsonlSessionStore', () => {
expect(getPath).toHaveBeenCalledTimes(2);
});

it('builds the manual compaction context with the run tool execution config', async () => {
mockSummarizer('summary of prior work');
mockRunCreate(createMockRun('answer'));
const toolExecution: t.ToolExecutionConfig = {
engine: 'local',
local: { codingToolNames: ['read_file'] },
};
const session = await createAgentSession({
cwd: dir,
sessionPath: join(dir, 'compact-tool-execution.jsonl'),
runId: 'template-run',
toolExecution,
graphConfig: {
type: 'standard',
llmConfig: { provider: 'openAI' as never, model: 'test-model' },
instructions: 'test',
},
});
await session.run('first');
const fromConfig = jest.spyOn(AgentContext, 'fromConfig');
await session.compact({ retainRecentTurns: 0 });
expect(fromConfig).toHaveBeenCalled();
expect(fromConfig.mock.calls.at(-1)?.[3]).toBe(toolExecution);
fromConfig.mockRestore();
});

it('keeps the unexposed cache coherent through compaction, HITL resume, branching, and reopening', async () => {
mockSummarizer('summary of prior work');
const mockRun = createMockRun('answer');
Expand Down
Loading
Loading