diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ApproximateRequestTokenEstimator.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ApproximateRequestTokenEstimator.java new file mode 100644 index 000000000..be62d7c13 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ApproximateRequestTokenEstimator.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.budget; + +import io.github.agentic.ai.agent.interceptor.ModelRequest; +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ContentPart; +import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.api.chat.ToolDefinition; + +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The default estimator: a character based approximation of the token consumption of + * a request. + * + *
+ * Unlike the message-only counters of the framework, the estimate covers the whole + * request which is finally dispatched: the system message, the conversation, the + * disclosed tool schemas and the reserved output. + * + *
+ * The tool schemas are counted for the bindings the request actually dispatches, + * matching the selection semantics of the dispatch path: the static and the dynamic + * registry are merged with the static binding winning a name conflict, and an + * explicit tool selection (including an empty one, which disables tools) filters the + * merged set. Schemas of bindings the dispatch will not send are not counted, so an + * empty selection or a large dynamic catalog cannot reject a request whose dispatch + * carries none of them. Tool descriptions are not counted: they feed the auxiliary + * selection call, never the dispatched request. + * + *
+ * When the request carries an explicit {@code maxTokens} output limit, the estimate
+ * accounts for that demand instead of the configured output reserve alone: the gate
+ * must refuse a request whose explicitly requested output does not fit into the
+ * window, even when the input itself is small.
+ */
+public final class ApproximateRequestTokenEstimator implements RequestTokenEstimator {
+
+ /**
+ * The average number of characters per token used by the approximation.
+ */
+ public static final int DEFAULT_CHARS_PER_TOKEN = 4;
+
+ private final int charsPerToken;
+
+ /**
+ * Creates an estimator with the default character ratio.
+ */
+ public ApproximateRequestTokenEstimator() {
+ this(DEFAULT_CHARS_PER_TOKEN);
+ }
+
+ /**
+ * Creates an estimator with a custom character ratio.
+ * @param charsPerToken the average number of characters per token, must be positive
+ */
+ public ApproximateRequestTokenEstimator(int charsPerToken) {
+ if (charsPerToken <= 0) {
+ throw new IllegalArgumentException("charsPerToken must be positive");
+ }
+ this.charsPerToken = charsPerToken;
+ }
+
+ @Override
+ public RequestTokenEstimate estimate(ModelRequest request, ContextWindowBudget budget) {
+ Objects.requireNonNull(request, "request must not be null");
+ Objects.requireNonNull(budget, "budget must not be null");
+ return new RequestTokenEstimate(systemTokens(request), messageTokens(request), toolSchemaTokens(request),
+ outputDemand(request, budget), EstimationAccuracy.APPROXIMATE);
+ }
+
+ /**
+ * The output demand of the request: an explicit {@code maxTokens} option counts as
+ * the demanded output, floored by the configured output reserve.
+ */
+ private int outputDemand(ModelRequest request, ContextWindowBudget budget) {
+ Integer maxTokens = request.getOptions() == null ? null : request.getOptions().maxTokens();
+ if (maxTokens == null) {
+ return budget.outputReserve();
+ }
+ return Math.max(budget.outputReserve(), maxTokens);
+ }
+
+ private int systemTokens(ModelRequest request) {
+ ChatMessage systemMessage = request.getSystemMessage();
+ return systemMessage == null ? 0 : messageTokens(systemMessage);
+ }
+
+ private int messageTokens(ModelRequest request) {
+ int total = 0;
+ for (ChatMessage message : request.getMessages()) {
+ total += messageTokens(message);
+ }
+ return total;
+ }
+
+ private int messageTokens(ChatMessage message) {
+ int total = 0;
+ for (ContentPart part : message.content()) {
+ if (part.text() != null) {
+ total += tokens(part.text());
+ }
+ else if (part.data() != null) {
+ total += tokens(part.data().length);
+ }
+ }
+ for (ToolCall call : message.toolCalls()) {
+ total += tokens(call.arguments());
+ }
+ return total;
+ }
+
+ private int toolSchemaTokens(ModelRequest request) {
+ // Merge both registries with the static binding winning a name conflict, then
+ // apply the explicit selection — the same semantics as the dispatch path.
+ Map
+ * The violation is a value, not a message: a caller can react to the classified
+ * component instead of parsing an error string.
+ *
+ * @param component the part which exceeded the budget
+ * @param estimatedTokens the estimated tokens of that part
+ * @param limitTokens the tokens available for it
+ */
+public record BudgetViolation(BudgetComponent component, int estimatedTokens, int limitTokens) {
+
+ /**
+ * Creates a budget violation.
+ * @param component the part which exceeded the budget, must not be null
+ * @param estimatedTokens the estimated tokens of that part, must not be negative
+ * @param limitTokens the tokens available for it, must not be negative
+ */
+ public BudgetViolation {
+ Objects.requireNonNull(component, "component must not be null");
+ if (estimatedTokens < 0 || limitTokens < 0) {
+ throw new IllegalArgumentException("token counts must not be negative");
+ }
+ }
+
+ /**
+ * Describes the violation of the total budget.
+ * @param estimate the estimate which exceeded the budget, must not be null
+ * @param budget the budget which was exceeded, must not be null
+ * @return the violation
+ */
+ public static BudgetViolation ofTotal(RequestTokenEstimate estimate, ContextWindowBudget budget) {
+ Objects.requireNonNull(estimate, "estimate must not be null");
+ Objects.requireNonNull(budget, "budget must not be null");
+ return new BudgetViolation(BudgetComponent.TOTAL, estimate.totalTokens(), budget.inputTokenLimit());
+ }
+
+ /**
+ * The tokens which exceeded the budget.
+ * @return the excess in tokens
+ */
+ public int excessTokens() {
+ return Math.max(0, estimatedTokens - limitTokens);
+ }
+
+}
diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ContextBudgetInterceptor.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ContextBudgetInterceptor.java
new file mode 100644
index 000000000..ed0e87689
--- /dev/null
+++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ContextBudgetInterceptor.java
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.budget;
+
+import io.github.agentic.ai.agent.interceptor.ModelCallHandler;
+import io.github.agentic.ai.agent.interceptor.ModelInterceptor;
+import io.github.agentic.ai.agent.interceptor.ModelRequest;
+import io.github.agentic.ai.agent.interceptor.ModelResponse;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * The gate which keeps a model request inside the context window of the model.
+ *
+ *
+ * The interceptor runs at the last point before the request is dispatched. It
+ * estimates the token consumption of the whole request, and when the request does not
+ * fit:
+ *
+ *
+ * A request which still does not fit is never dispatched. The interceptor fails with
+ * a {@link ContextWindowBudgetExceededException} instead, so an oversized request
+ * cannot reach the provider.
+ *
+ *
+ * The interceptor is opt-in and does not change the behaviour of an agent unless it
+ * is registered in the interceptor chain.
+ */
+public final class ContextBudgetInterceptor extends ModelInterceptor {
+
+ private final ContextWindowBudget budget;
+
+ private final RequestTokenEstimator estimator;
+
+ private final RequestCompactor compactor;
+
+ private ContextBudgetInterceptor(Builder builder) {
+ this.budget = builder.budget;
+ this.estimator = builder.estimator;
+ this.compactor = builder.compactor;
+ }
+
+ /**
+ * Creates a builder for the interceptor.
+ * @return a new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Ordering contract: this interceptor must be registered **after** (i.e. more
+ * innermost than) every interceptor that rewrites the request. The budget is
+ * checked against the request as seen at this interceptor's position in the
+ * chain — an outer rewrite that grows the request after the budget check has
+ * passed would bypass the gate (a chain-level regression test documents this).
+ * Terminal, non-bypassable enforcement belongs to the runtime dispatch (see
+ * issue #137 discussion).
+ */
+ public static final String ORDERING_CONTRACT = "register the context budget interceptor"
+ + " after every request-rewriting interceptor";
+
+ @Override
+ public ModelResponse interceptModel(ModelRequest request, ModelCallHandler handler) {
+ Objects.requireNonNull(request, "request must not be null");
+ Objects.requireNonNull(handler, "handler must not be null");
+ RequestTokenEstimate estimate = estimator.estimate(request, budget);
+ if (estimate.totalTokens() <= budget.inputTokenLimit()) {
+ return handler.call(request);
+ }
+ ModelRequest compacted = request;
+ if (compactor != null) {
+ compacted = Objects.requireNonNull(compactor.compact(request), "compactor must not return null");
+ Optional
+ * A budget has two parts. The {@code inputTokenLimit} is the total context window of
+ * the model, and the {@code outputReserve} is the part of that window which is kept
+ * for the completion. Everything the request carries - the system message, the
+ * conversation, the tool schemas and the reserved output - has to fit into the
+ * window together.
+ *
+ *
+ * The budget describes the window of one model, not of a run. A run that talks to
+ * several models carries one budget per model.
+ */
+public record ContextWindowBudget(int inputTokenLimit, int outputReserve) {
+
+ /**
+ * Creates a context window budget.
+ * @param inputTokenLimit the total context window of the model, must be positive
+ * @param outputReserve the tokens reserved for the completion, must be positive and
+ * smaller than the window
+ */
+ public ContextWindowBudget {
+ if (inputTokenLimit <= 0) {
+ throw new IllegalArgumentException("inputTokenLimit must be positive");
+ }
+ if (outputReserve <= 0) {
+ throw new IllegalArgumentException("outputReserve must be positive");
+ }
+ if (outputReserve >= inputTokenLimit) {
+ throw new IllegalArgumentException("outputReserve must be smaller than inputTokenLimit");
+ }
+ }
+
+ /**
+ * The number of tokens which may be spent on the request itself.
+ * @return the usable input budget
+ */
+ public int usableInputTokens() {
+ return inputTokenLimit - outputReserve;
+ }
+
+}
diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ContextWindowBudgetExceededException.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ContextWindowBudgetExceededException.java
new file mode 100644
index 000000000..530052703
--- /dev/null
+++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ContextWindowBudgetExceededException.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.budget;
+
+import java.util.Objects;
+
+/**
+ * Thrown when a request does not fit into the context window of the model and could
+ * not be compressed into it.
+ *
+ *
+ * The failure is explicit. The request is not dispatched, and it is not silently
+ * truncated either: a caller which cannot fit the request has to decide how to
+ * continue.
+ */
+public class ContextWindowBudgetExceededException extends RuntimeException {
+
+ private final BudgetViolation violation;
+
+ private final RequestTokenEstimate estimate;
+
+ /**
+ * Creates the exception.
+ * @param violation the classified violation, must not be null
+ * @param estimate the estimate which exceeded the budget, must not be null
+ */
+ public ContextWindowBudgetExceededException(BudgetViolation violation, RequestTokenEstimate estimate) {
+ super("the request does not fit into the context window: " + violation.estimatedTokens() + " estimated tokens, "
+ + violation.limitTokens() + " available, dominant part is " + estimate.dominantComponent());
+ this.violation = violation;
+ this.estimate = estimate;
+ }
+
+ /**
+ * The classified violation.
+ * @return the violation, never null
+ */
+ public BudgetViolation violation() {
+ return violation;
+ }
+
+ /**
+ * The estimate which exceeded the budget.
+ * @return the estimate, never null
+ */
+ public RequestTokenEstimate estimate() {
+ return estimate;
+ }
+
+ /**
+ * The part of the request which consumes the most tokens.
+ * @return the dominant component, never null
+ */
+ public BudgetComponent dominantComponent() {
+ return estimate.dominantComponent();
+ }
+
+ /**
+ * Creates the exception for an estimate which exceeded a budget.
+ * @param estimate the estimate which exceeded the budget, must not be null
+ * @param budget the budget which was exceeded, must not be null
+ * @return the exception
+ */
+ public static ContextWindowBudgetExceededException of(RequestTokenEstimate estimate, ContextWindowBudget budget) {
+ Objects.requireNonNull(estimate, "estimate must not be null");
+ Objects.requireNonNull(budget, "budget must not be null");
+ return new ContextWindowBudgetExceededException(BudgetViolation.ofTotal(estimate, budget), estimate);
+ }
+
+}
diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/EstimationAccuracy.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/EstimationAccuracy.java
new file mode 100644
index 000000000..a695be92f
--- /dev/null
+++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/EstimationAccuracy.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.budget;
+
+/**
+ * How accurate a token estimate is.
+ *
+ *
+ * The distinction matters because a budget gate that believes it knows the exact
+ * token count behaves differently from a gate that only approximates it. A caller
+ * which cannot tolerate an approximation has to plug in an exact estimator instead
+ * of trusting a heuristic.
+ */
+public enum EstimationAccuracy {
+
+ /**
+ * The count is the exact number of tokens the provider will consume.
+ */
+ EXACT,
+
+ /**
+ * The count is a heuristic and may be lower or higher than the real usage.
+ */
+ APPROXIMATE,
+
+ /**
+ * The count never underestimates the real usage.
+ */
+ CONSERVATIVE
+
+}
diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestCompactor.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestCompactor.java
new file mode 100644
index 000000000..d82e52c8a
--- /dev/null
+++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestCompactor.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.budget;
+
+import io.github.agentic.ai.agent.interceptor.ModelRequest;
+
+/**
+ * Shrinks a request which does not fit into the context window of the model.
+ *
+ *
+ * The compactor is the seam between the budget and the existing compression
+ * capabilities. It does not define how a request is compressed; it only defines the
+ * result: a request which is not larger than the one it received.
+ *
+ *
+ * A compactor which cannot compress anything returns the request unchanged. The
+ * budget gate then re-estimates the request and fails explicitly instead of
+ * dispatching an oversized request.
+ */
+@FunctionalInterface
+public interface RequestCompactor {
+
+ /**
+ * Compresses a request.
+ * @param request the request to compress, never null
+ * @return the compressed request, never null
+ */
+ ModelRequest compact(ModelRequest request);
+
+}
diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestTokenEstimate.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestTokenEstimate.java
new file mode 100644
index 000000000..9b8b73a21
--- /dev/null
+++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestTokenEstimate.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.budget;
+
+import java.util.Objects;
+
+/**
+ * The estimated token consumption of one model request.
+ *
+ *
+ * The estimate is decomposed instead of being a single number, because the reaction
+ * to an oversized request depends on which part caused it: a large tool schema is
+ * handled by selecting fewer tools, a large conversation by summarizing it, and a
+ * large output reserve by asking for a shorter answer.
+ *
+ * @param systemTokens the estimated tokens of the system message
+ * @param messageTokens the estimated tokens of the conversation
+ * @param toolSchemaTokens the estimated tokens of the disclosed tool schemas
+ * @param outputReserve the tokens reserved for the completion
+ * @param accuracy how accurate the estimate is
+ */
+public record RequestTokenEstimate(int systemTokens, int messageTokens, int toolSchemaTokens, int outputReserve,
+ EstimationAccuracy accuracy) {
+
+ /**
+ * Creates a token estimate.
+ * @param systemTokens the estimated tokens of the system message, must not be
+ * negative
+ * @param messageTokens the estimated tokens of the conversation, must not be
+ * negative
+ * @param toolSchemaTokens the estimated tokens of the disclosed tool schemas, must
+ * not be negative
+ * @param outputReserve the tokens reserved for the completion, must not be negative
+ * @param accuracy how accurate the estimate is, must not be null
+ */
+ public RequestTokenEstimate {
+ if (systemTokens < 0 || messageTokens < 0 || toolSchemaTokens < 0 || outputReserve < 0) {
+ throw new IllegalArgumentException("token estimates must not be negative");
+ }
+ Objects.requireNonNull(accuracy, "accuracy must not be null");
+ }
+
+ /**
+ * The estimated tokens of the request itself, without the reserved output.
+ * @return the estimated input tokens
+ */
+ public int inputTokens() {
+ return systemTokens + messageTokens + toolSchemaTokens;
+ }
+
+ /**
+ * The estimated tokens the request and the reserved output consume together.
+ * @return the estimated total tokens
+ */
+ public int totalTokens() {
+ return inputTokens() + outputReserve;
+ }
+
+ /**
+ * The part of the request which consumes the most tokens.
+ * @return the dominant component, never null
+ */
+ public BudgetComponent dominantComponent() {
+ if (toolSchemaTokens >= messageTokens && toolSchemaTokens >= systemTokens && toolSchemaTokens >= outputReserve) {
+ return BudgetComponent.TOOL_SCHEMA;
+ }
+ if (messageTokens >= systemTokens && messageTokens >= outputReserve) {
+ return BudgetComponent.MESSAGES;
+ }
+ if (systemTokens >= outputReserve) {
+ return BudgetComponent.SYSTEM;
+ }
+ return BudgetComponent.OUTPUT_RESERVE;
+ }
+
+}
diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestTokenEstimator.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestTokenEstimator.java
new file mode 100644
index 000000000..a15d61930
--- /dev/null
+++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/RequestTokenEstimator.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.budget;
+
+import io.github.agentic.ai.agent.interceptor.ModelRequest;
+
+/**
+ * Estimates how many tokens a model request consumes.
+ *
+ *
+ * The estimator is the replaceable part of the budget contract. The default
+ * implementation approximates, a provider specific implementation can be exact. The
+ * budget gate does not care which one it is given, it only reads the resulting
+ * estimate and its accuracy.
+ */
+@FunctionalInterface
+public interface RequestTokenEstimator {
+
+ /**
+ * Estimates the token consumption of a request.
+ * @param request the request to estimate, must not be null
+ * @param budget the budget of the target model, must not be null
+ * @return the estimate, never null
+ */
+ RequestTokenEstimate estimate(ModelRequest request, ContextWindowBudget budget);
+
+}
diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ToolCallPairingValidator.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ToolCallPairingValidator.java
new file mode 100644
index 000000000..69cb4acf3
--- /dev/null
+++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/budget/ToolCallPairingValidator.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.budget;
+
+import io.github.agentic.ai.api.chat.ChatMessage;
+import io.github.agentic.ai.api.chat.ChatRole;
+import io.github.agentic.ai.api.chat.ToolCall;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Validates that the tool calls of a conversation and their results stay paired.
+ *
+ *
+ * Compression may remove a message. When it removes a tool result, the assistant
+ * message which requested the tool call is left with an unanswered call; when it
+ * removes the assistant message, the tool result is left without a call. Both break
+ * the conversation, so the budget gate refuses to dispatch such a request. The
+ * validation is position-aware: a result must follow the assistant message which
+ * requested the call, and neither calls nor results may repeat.
+ */
+public final class ToolCallPairingValidator {
+
+ private ToolCallPairingValidator() {
+ }
+
+ /**
+ * The kind of a pairing violation.
+ */
+ public enum Kind {
+
+ /**
+ * A tool call has no matching tool result.
+ */
+ MISSING_TOOL_RESULT,
+
+ /**
+ * A tool result has no matching tool call.
+ */
+ UNKNOWN_TOOL_RESULT,
+
+ /**
+ * The same tool call appears more than once.
+ */
+ DUPLICATE_TOOL_CALL,
+
+ /**
+ * The same tool result appears more than once.
+ */
+ DUPLICATE_TOOL_RESULT,
+
+ /**
+ * A tool result appears before the assistant message which requested the call.
+ */
+ RESULT_BEFORE_CALL
+ }
+
+ /**
+ * A pairing violation.
+ *
+ * @param kind the kind of the violation, never null
+ * @param toolCallId the identifier of the affected tool call, never null
+ */
+ public record Violation(Kind kind, String toolCallId) {
+
+ /**
+ * Creates a pairing violation.
+ * @param kind the kind of the violation, must not be null
+ * @param toolCallId the identifier of the affected tool call, must not be null
+ */
+ public Violation {
+ Objects.requireNonNull(kind, "kind must not be null");
+ Objects.requireNonNull(toolCallId, "toolCallId must not be null");
+ }
+ }
+
+ /**
+ * Validates the pairing of the tool calls and the tool results of a
+ * conversation.
+ * @param messages the conversation, must not be null
+ * @return the violation, or empty when the conversation is paired
+ */
+ public static Optional
+ * The tests are model independent: no provider is called, the request is measured
+ * and the dispatch is counted. The invariant under test is that an oversized request
+ * never reaches the provider.
+ */
+class ContextBudgetInterceptorTest {
+
+ @Test
+ void theEstimateCoversSystemMessagesToolSchemasAndTheOutputReserve() {
+ RequestTokenEstimate estimate = new ApproximateRequestTokenEstimator().estimate(request(), budget());
+
+ assertThat(estimate.systemTokens()).isPositive();
+ assertThat(estimate.messageTokens()).isPositive();
+ assertThat(estimate.toolSchemaTokens()).isPositive();
+ assertThat(estimate.outputReserve()).isEqualTo(100);
+ assertThat(estimate.totalTokens()).isEqualTo(estimate.inputTokens() + 100);
+ assertThat(estimate.accuracy()).isEqualTo(EstimationAccuracy.APPROXIMATE);
+ }
+
+ // issue #137 review: an explicit maxTokens output limit is an output demand —
+ // a request whose requested completion alone exceeds the window must be refused
+ // even when its input is small.
+ @Test
+ void anExplicitOutputDemandBeyondTheWindowIsRefused() {
+ AtomicInteger dispatches = new AtomicInteger();
+ ContextBudgetInterceptor interceptor = ContextBudgetInterceptor.builder()
+ .budget(new ContextWindowBudget(1000, 100))
+ .build();
+ ModelRequest demanded = ModelRequest.builder()
+ .systemMessage(ChatMessage.system("system instructions for the agent"))
+ .messages(List.of(ChatMessage.user("hello")))
+ .options(GenerationOptions.builder().maxTokens(2000).build())
+ .build();
+
+ assertThatThrownBy(() -> interceptor.interceptModel(demanded, r -> {
+ dispatches.incrementAndGet();
+ return response();
+ })).isInstanceOf(ContextWindowBudgetExceededException.class)
+ .satisfies(failure -> {
+ ContextWindowBudgetExceededException exceeded = (ContextWindowBudgetExceededException) failure;
+ assertThat(exceeded.dominantComponent()).isEqualTo(BudgetComponent.OUTPUT_RESERVE);
+ });
+
+ assertThat(dispatches).hasValue(0);
+ }
+
+ // The same gate must not reject a request whose explicit output limit fits.
+ @Test
+ void anExplicitOutputDemandWithinTheWindowStillDispatches() {
+ AtomicInteger dispatches = new AtomicInteger();
+ ContextBudgetInterceptor interceptor = ContextBudgetInterceptor.builder()
+ .budget(new ContextWindowBudget(1000, 100))
+ .build();
+ ModelRequest modest = ModelRequest.builder()
+ .systemMessage(ChatMessage.system("system instructions for the agent"))
+ .messages(List.of(ChatMessage.user("hello")))
+ .options(GenerationOptions.builder().maxTokens(50).build())
+ .build();
+
+ interceptor.interceptModel(modest, r -> {
+ dispatches.incrementAndGet();
+ return response();
+ });
+
+ assertThat(dispatches).hasValue(1);
+ }
+
+ // The estimate itself accounts for the explicit output demand, floored by the
+ // configured reserve.
+ @Test
+ void theEstimateAccountsForAnExplicitMaxTokens() {
+ RequestTokenEstimate estimate = new ApproximateRequestTokenEstimator().estimate(request(), budget());
+
+ ModelRequest demanded = ModelRequest.builder()
+ .messages(List.of(ChatMessage.user("hello")))
+ .options(GenerationOptions.builder().maxTokens(2000).build())
+ .build();
+ RequestTokenEstimate demandedEstimate = new ApproximateRequestTokenEstimator().estimate(demanded, budget());
+
+ assertThat(estimate.outputReserve()).isEqualTo(100);
+ assertThat(demandedEstimate.outputReserve()).isEqualTo(2000);
+ assertThat(demandedEstimate.totalTokens()).isEqualTo(demandedEstimate.inputTokens() + 2000);
+ }
+
+ // issue #137 review: the tool schemas must match what the dispatch actually sends.
+ // The empty explicit selection disables tools, so a large dynamic catalog must not
+ // count against the request.
+ @Test
+ void anEmptyToolSelectionDoesNotCountDynamicSchemas() {
+ AtomicInteger dispatches = new AtomicInteger();
+ ContextBudgetInterceptor interceptor = ContextBudgetInterceptor.builder()
+ .budget(new ContextWindowBudget(1000, 100))
+ .build();
+ ModelRequest disabled = ModelRequest.builder()
+ .systemMessage(ChatMessage.system("system instructions for the agent"))
+ .messages(List.of(ChatMessage.user("hello")))
+ .toolRegistry(new ToolRegistry(List.of()))
+ .dynamicToolRegistry(new ToolRegistry(List.of(tool("catalog", "{\"type\":\"object\",\"properties\":{"
+ + "field1\":{\"type\":\"string\"},field2:{\"type\":\"string\"}}}"))))
+ .tools(List.of())
+ .build();
+ RequestTokenEstimate estimate = new ApproximateRequestTokenEstimator().estimate(disabled, budget());
+ assertThat(estimate.toolSchemaTokens()).isZero();
+
+ interceptor.interceptModel(disabled, r -> {
+ dispatches.incrementAndGet();
+ return response();
+ });
+ assertThat(dispatches).hasValue(1);
+ }
+
+ // An explicit selection filters the dynamic registry exactly like the dispatch does.
+ @Test
+ void anExplicitSelectionFiltersDynamicSchemas() {
+ ModelRequest selected = ModelRequest.builder()
+ .systemMessage(ChatMessage.system("system instructions for the agent"))
+ .messages(List.of(ChatMessage.user("hello")))
+ .toolRegistry(new ToolRegistry(List.of(tool("lookup", "{\"type\":\"object\"}"))))
+ .dynamicToolRegistry(new ToolRegistry(List.of(tool("catalog", "{\"type\":\"object\",\"properties\":{"
+ + "field1\":{\"type\":\"string\"},field2:{\"type\":\"string\"}}}"))))
+ .tools(List.of("lookup"))
+ .build();
+ RequestTokenEstimate estimate = new ApproximateRequestTokenEstimator().estimate(selected, budget());
+
+ ModelRequest staticOnly = ModelRequest.builder()
+ .systemMessage(ChatMessage.system("system instructions for the agent"))
+ .messages(List.of(ChatMessage.user("hello")))
+ .toolRegistry(new ToolRegistry(List.of(tool("lookup", "{\"type\":\"object\"}"))))
+ .build();
+ RequestTokenEstimate staticEstimate = new ApproximateRequestTokenEstimator().estimate(staticOnly, budget());
+
+ assertThat(estimate.toolSchemaTokens()).isEqualTo(staticEstimate.toolSchemaTokens());
+ }
+
+ // A dynamic binding shadowed by a static name is dispatched once — the schema is
+ // counted once, not twice.
+ @Test
+ void aDynamicSchemaShadowedByAStaticNameIsCountedOnce() {
+ ModelRequest shadowed = ModelRequest.builder()
+ .systemMessage(ChatMessage.system("system instructions for the agent"))
+ .messages(List.of(ChatMessage.user("hello")))
+ .toolRegistry(new ToolRegistry(List.of(tool("lookup", "{\"type\":\"object\"}"))))
+ .dynamicToolRegistry(new ToolRegistry(List.of(tool("lookup", "{\"type\":\"object\",\"properties\":{"
+ + "field1\":{\"type\":\"string\"},field2:{\"type\":\"string\"}}}"))))
+ .build();
+ RequestTokenEstimate estimate = new ApproximateRequestTokenEstimator().estimate(shadowed, budget());
+
+ ModelRequest staticOnly = ModelRequest.builder()
+ .systemMessage(ChatMessage.system("system instructions for the agent"))
+ .messages(List.of(ChatMessage.user("hello")))
+ .toolRegistry(new ToolRegistry(List.of(tool("lookup", "{\"type\":\"object\"}"))))
+ .build();
+ RequestTokenEstimate staticEstimate = new ApproximateRequestTokenEstimator().estimate(staticOnly, budget());
+
+ assertThat(estimate.toolSchemaTokens()).isEqualTo(staticEstimate.toolSchemaTokens());
+ }
+
+ // issue #137 review: the budget must hold after interceptor rewrites, so the
+ // budget interceptor has to sit **after** every request-rewriting interceptor
+ // in the chain (innermost). This chain-level case registers [rewriter, budget]:
+ // a small request passes the rewriter, grows beyond the window, and must be
+ // refused before reaching the provider.
+ @Test
+ void budgetStillGuardsAfterARequestRewritingInterceptor() throws Exception {
+ AtomicInteger dispatches = new AtomicInteger();
+ ModelInterceptor rewriter = new ModelInterceptor() {
+ public String getName() {
+ return "rewriter";
+ }
+
+ public ModelResponse interceptModel(ModelRequest request, ModelCallHandler next) {
+ // Rewrites the small incoming request into an oversized one.
+ return next.call(longRequest());
+ }
+ };
+ ContextBudgetInterceptor budget = ContextBudgetInterceptor.builder()
+ .budget(new ContextWindowBudget(20, 5))
+ .build();
+ // Registration order: the rewriter outermost, the budget innermost (the
+ // terminal position, right before the dispatch).
+ ModelCallHandler chain = request -> {
+ dispatches.incrementAndGet();
+ return response();
+ };
+ ModelCallHandler budgetHandler = request -> budget.interceptModel(request, chain);
+ ModelCallHandler fullChain = request -> rewriter.interceptModel(request, budgetHandler);
+
+ assertThatThrownBy(() -> fullChain.call(request()))
+ .isInstanceOf(ContextWindowBudgetExceededException.class);
+ assertThat(dispatches).hasValue(0);
+ }
+ @Test
+ void anOversizedRequestIsNeverDispatched() {
+ AtomicInteger dispatches = new AtomicInteger();
+ ContextBudgetInterceptor interceptor = ContextBudgetInterceptor.builder()
+ .budget(new ContextWindowBudget(20, 5))
+ .build();
+
+ assertThatThrownBy(() -> interceptor.interceptModel(request(), r -> {
+ dispatches.incrementAndGet();
+ return response();
+ })).isInstanceOf(ContextWindowBudgetExceededException.class)
+ .satisfies(failure -> {
+ ContextWindowBudgetExceededException exceeded = (ContextWindowBudgetExceededException) failure;
+ assertThat(exceeded.violation().component()).isEqualTo(BudgetComponent.TOTAL);
+ assertThat(exceeded.violation().excessTokens()).isPositive();
+ assertThat(exceeded.dominantComponent()).isNotNull();
+ });
+
+ assertThat(dispatches).hasValue(0);
+ }
+
+ @Test
+ void aCompactedRequestIsMeasuredAgainBeforeItIsDispatched() {
+ AtomicInteger dispatches = new AtomicInteger();
+ AtomicReference
+ * The response states that the result is not available. It never points to a file
+ * which was not written.
+ * @param response the original tool response
+ * @param reason the reason why the result could not be persisted
+ * @return the failed eviction response
+ */
+ private ToolCallResponse evictionFailed(ToolCallResponse response, String reason) {
+ return ToolCallResponse.builder()
+ .content(reason)
+ .toolName(response.getToolName())
+ .toolCallId(response.getToolCallId())
+ .status(ToolCallResponse.ERROR_STATUS)
+ .metadata(response.getMetadata())
+ .build();
+ }
+
/**
* Sanitize tool call ID for use in file paths.
* Removes non-alphanumeric characters except underscore and hyphen.
diff --git a/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/hook/messages/SummarizationRequestCompactor.java b/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/hook/messages/SummarizationRequestCompactor.java
new file mode 100644
index 000000000..e98df6b2f
--- /dev/null
+++ b/argi-orchestration/argi-agent-framework/src/main/java/io/github/agentic/ai/agent/orchestration/hook/messages/SummarizationRequestCompactor.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.github.agentic.ai.agent.orchestration.hook.messages;
+
+import io.github.agentic.ai.agent.budget.RequestCompactor;
+import io.github.agentic.ai.agent.interceptor.ModelRequest;
+import io.github.agentic.ai.agent.orchestration.hook.summarization.SummarizationHook;
+import io.github.agentic.ai.api.chat.ChatMessage;
+import io.github.agentic.ai.workflow.graph.RunnableConfig;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Compresses a model request with the existing {@link SummarizationHook}.
+ *
+ *
+ * The adapter is the bridge between the neutral budget contract and the existing
+ * compression capability. It does not summarize anything itself: it hands the
+ * conversation to the hook and rebuilds the request with the messages the hook
+ * returned.
+ *
+ *
+ * The behaviour of the hook is not changed. When the hook decides that no summary
+ * is needed, or when the summary fails and the hook keeps the original history, the
+ * original request is returned unchanged and the budget gate decides what to do with
+ * it.
+ */
+public final class SummarizationRequestCompactor implements RequestCompactor {
+
+ private final SummarizationHook hook;
+
+ private final RunnableConfig config;
+
+ /**
+ * Creates a compactor which summarizes without a run configuration.
+ * @param hook the summarization hook, must not be null
+ */
+ public SummarizationRequestCompactor(SummarizationHook hook) {
+ this(hook, null);
+ }
+
+ /**
+ * Creates a compactor which summarizes within a run.
+ * @param hook the summarization hook, must not be null
+ * @param config the run configuration, nullable
+ */
+ public SummarizationRequestCompactor(SummarizationHook hook, RunnableConfig config) {
+ this.hook = Objects.requireNonNull(hook, "hook must not be null");
+ this.config = config;
+ }
+
+ @Override
+ public ModelRequest compact(ModelRequest request) {
+ Objects.requireNonNull(request, "request must not be null");
+ AgentCommand command = this.hook.beforeModel(request.getMessages(), this.config);
+ List
+ *
+ *
+ *