From a29ee13df5feb9fb85a8c9494e656b209db105b3 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Thu, 29 Oct 2026 15:54:21 +0800 Subject: [PATCH 01/12] feat(agent): add engine-neutral model-turn journal contract --- argi-agent/argi-agent-core/pom.xml | 4 + .../ai/agent/durable/ModelTurnCodec.java | 312 +++++++++++++++++ .../durable/ModelTurnJournalException.java | 39 +++ .../ai/agent/durable/ModelTurnRecorder.java | 262 +++++++++++++++ .../ai/agent/durable/package-info.java | 37 +++ .../agent/durable/ModelTurnRecorderTest.java | 313 ++++++++++++++++++ .../execution/run/MemoryRunJournal.java | 95 ++++++ .../ai/runtime/execution/run/RunJournal.java | 66 ++++ .../execution/run/MemoryRunJournalTest.java | 53 +++ .../execution/run/RunJournalContractTest.java | 100 ++++++ 10 files changed, 1281 insertions(+) create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java create mode 100644 argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java create mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java create mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java create mode 100644 argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java create mode 100644 argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java diff --git a/argi-agent/argi-agent-core/pom.xml b/argi-agent/argi-agent-core/pom.xml index 4994be950..2e8a1967e 100644 --- a/argi-agent/argi-agent-core/pom.xml +++ b/argi-agent/argi-agent-core/pom.xml @@ -44,6 +44,10 @@ io.github.agentic-ai-java argi-runtime-api + + io.github.agentic-ai-java + argi-runtime-core + com.fasterxml.jackson.core jackson-databind diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java new file mode 100644 index 000000000..668f7916d --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java @@ -0,0 +1,312 @@ +/* + * Copyright 2025-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.durable; + +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatRequest; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.ContentPart; +import io.github.agentic.ai.api.chat.GenerationOptions; +import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.api.chat.ToolDefinition; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.json.JsonMapper; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * Version-tagged JSON payloads for model-turn journal entries. + * + *

+ * The neutral chat contracts are bound by their declared record components, not by a + * hand-written field-by-field codec: the payload and the type stay described in one place, + * and a codec bug cannot desynchronise the write and read paths. What is written by hand + * is only where the recorded shape must deliberately differ from the type: + *

+ * + *

+ * No Java type information is written: this mapper never enables default typing, so a + * payload stays readable by any process and after a class moves. Unknown fields are + * ignored on read so a newer writer can add fields without invalidating old records, and + * property and map-key ordering is pinned so equal data always produces equal JSON - which + * is what makes {@link #requestHash(ChatRequest)} stable. + * + *

+ * Request bodies are never journaled. Responses and tool arguments are, because replaying + * them is the purpose of the journal; applications apply their data-protection policy to + * the journal backend. + */ +public final class ModelTurnCodec { + + /** Version of every payload schema defined here, carried by {@code RunPayload}. */ + public static final int SCHEMA_VERSION = 1; + + /** {@code MODEL_CALL}: the request fingerprint, written before the provider call. */ + public static final String REQUEST_TYPE = "argi.model-request"; + + /** {@code MODEL_CALL}: the complete terminal response. */ + public static final String RESPONSE_TYPE = "argi.model-response"; + + /** {@code TASK_CALL}: one planned tool call, committed with its response. */ + public static final String PLAN_TYPE = "argi.tool-plan"; + + /** {@code EVENT}: streamed text received before the terminal response. */ + public static final String DELTA_TYPE = "argi.model-delta"; + + /** + * Metadata that belongs to one process run: protocol markers and handles into + * in-memory tool-binding snapshots. A recovering invocation re-derives these, so + * recording them would only replay stale state - and a snapshot id that changes per + * process would change the request fingerprint across a restart. + */ + private static final Set PROCESS_LOCAL_METADATA = Set.of("argiFinalResponse", "argiTextDelta", + "argi.toolBindingSnapshot", "argi.toolBindingSelection", "argi.dynamicToolBindings", "argiModelFailure"); + + private static final JsonMapper MAPPER = JsonMapper.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .build(); + + private ModelTurnCodec() { + } + + /** + * Canonical fingerprint of the request a provider actually receives: ordered messages, + * disclosed tool definitions and effective generation options. + * + *

+ * {@link ChatRequest#attributes()} is deliberately excluded. It is a transport escape + * hatch whose meaning differs per adapter (Spring AI passes it to advisor parameters, + * LangChain4j to listener attributes, Google ADK only checks emptiness), and in the + * graph-independent engine it carries the whole mutable agent state. Hashing it would + * bind request identity to adapter internals and to state that changes for reasons + * unrelated to the request. An application whose adapters derive generation semantics + * from attributes must not rely on replay across differing attribute values. + */ + public static String requestHash(ChatRequest request) { + Objects.requireNonNull(request, "request cannot be null"); + RequestFingerprint fingerprint = new RequestFingerprint(sanitize(request.messages()), request.tools(), + request.options()); + return sha256(write(fingerprint)); + } + + /** The {@link #REQUEST_TYPE} payload: the fingerprint only, never the request body. */ + public static String encodeRequest(ChatRequest request) { + return write(new RequestRecord(requestHash(request))); + } + + /** Reads the fingerprint recorded in a {@link #REQUEST_TYPE} payload. */ + public static String decodeRequestHash(String json) { + return read(json, RequestRecord.class).requestHash(); + } + + /** The {@link #RESPONSE_TYPE} payload. */ + public static String encodeResponse(ChatResponse response) { + return write(sanitize(Objects.requireNonNull(response, "response cannot be null"))); + } + + public static ChatResponse decodeResponse(String json) { + return read(json, ChatResponse.class); + } + + /** The {@link #DELTA_TYPE} payload. */ + public static String encodeDelta(String text) { + return write(new Delta(text)); + } + + /** + * The {@link #PLAN_TYPE} payload. The tool-call id assigned by the provider is + * deliberately absent: it correlates protocol messages, it is not a task identity. + * + *

+ * Arguments are stored and hashed in canonical form, so two semantically equal + * invocations that differ only in whitespace or object-key order produce the same + * record and the same hash. The hash is intended to become an idempotency key for + * external effects, which only holds if formatting cannot change it. + */ + public static String encodeToolPlan(ToolCall call, ToolDefinition definition, int ordinal) { + String arguments = canonicalArguments(call.arguments()); + return write(new ToolPlan(ordinal, call.name(), definition == null ? null : definitionVersion(definition), + sha256(arguments), arguments)); + } + + /** + * Canonical form of a tool-argument JSON string: object keys sorted recursively, + * compact output. + * + *

+ * Keys are reordered explicitly rather than through a mapper feature: the mapper's + * ordering settings apply to POJO properties and {@code Map} entries, not to an + * {@code ObjectNode}, which preserves insertion order. Only objects are reordered. + * Array order carries meaning (argument lists are positional) and scalars have no order. + * Malformed JSON is returned unchanged rather than rejected: argument validation is + * opt-in, so a provider may legitimately produce arguments this codec cannot parse, and + * that must not fail a turn that would otherwise dispatch. + */ + public static String canonicalArguments(String arguments) { + if (arguments == null || arguments.isBlank()) { + return arguments; + } + try { + JsonNode tree = MAPPER.readTree(arguments); + return tree == null || !tree.isContainerNode() ? arguments : write(sortKeys(tree)); + } + catch (JsonProcessingException malformed) { + return arguments; + } + } + + private static JsonNode sortKeys(JsonNode node) { + if (node.isObject()) { + ObjectNode sorted = MAPPER.createObjectNode(); + TreeSet keys = new TreeSet<>(); + node.fieldNames().forEachRemaining(keys::add); + keys.forEach(key -> sorted.set(key, sortKeys(node.get(key)))); + return sorted; + } + if (node.isArray()) { + ArrayNode values = MAPPER.createArrayNode(); + node.forEach(child -> values.add(sortKeys(child))); + return values; + } + return node; + } + + /** Stable identity of one tool definition; a changed schema must not reuse a task id. */ + public static String definitionVersion(ToolDefinition definition) { + return sha256(write(new DefinitionRecord(definition.name(), definition.description(), + definition.inputSchema()))); + } + + private static List sanitize(List messages) { + return messages == null ? List.of() : messages.stream().map(ModelTurnCodec::sanitize).toList(); + } + + private static ChatResponse sanitize(ChatResponse response) { + return new ChatResponse(response.id(), response.model(), sanitize(response.messages()), response.toolCalls(), + response.finishReason(), response.usage(), sanitize(response.metadata())); + } + + private static ChatMessage sanitize(ChatMessage message) { + List content = message.content() + .stream() + .map(part -> new ContentPart(part.type(), part.text(), part.mimeType(), part.data(), + sanitize(part.metadata()))) + .toList(); + // Tool arguments are canonicalised here too: an assistant message replaying earlier + // calls must fingerprint identically no matter how the provider formatted them. + List toolCalls = message.toolCalls() + .stream() + .map(call -> new ToolCall(call.id(), call.name(), canonicalArguments(call.arguments()))) + .toList(); + return new ChatMessage(message.role(), content, toolCalls, message.toolCallId(), message.toolName(), + sanitize(message.metadata())); + } + + private static Map sanitize(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return Map.of(); + } + Map retained = new LinkedHashMap<>(); + metadata.forEach((key, value) -> { + if (key != null && value != null && !PROCESS_LOCAL_METADATA.contains(key)) { + retained.put(key, value); + } + }); + return retained; + } + + private static T read(String json, Class type) { + try { + return MAPPER.readValue(Objects.requireNonNull(json, "journal payload cannot be null"), type); + } + catch (JsonProcessingException ex) { + throw new ModelTurnJournalException("Cannot decode journal payload as " + type.getSimpleName(), ex); + } + } + + private static String write(Object payload) { + try { + return MAPPER.writeValueAsString(payload); + } + catch (JsonProcessingException ex) { + throw new ModelTurnJournalException("Cannot encode journal payload " + payload.getClass().getSimpleName(), + ex); + } + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } + catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is required by the Java platform", ex); + } + } + + /** Stored shape of {@link #REQUEST_TYPE}: the hash only, never the request body. */ + private record RequestRecord(String requestHash) { + } + + /** + * Hashed shape of a request. Excludes {@link ChatRequest#attributes()} on purpose; see + * {@link #requestHash(ChatRequest)}. + */ + private record RequestFingerprint(List messages, List tools, + GenerationOptions options) { + } + + /** Stored shape of {@link #PLAN_TYPE}; excludes the provider's tool-call id. */ + private record ToolPlan(int ordinal, String toolName, String definitionVersion, String argumentsHash, + String arguments) { + } + + /** Hashed shape of a tool definition; a changed schema must not reuse a task id. */ + private record DefinitionRecord(String name, String description, String inputSchema) { + } + + /** Stored shape of {@link #DELTA_TYPE}. */ + private record Delta(String text) { + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java new file mode 100644 index 000000000..ad67e7143 --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025-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.durable; + +/** + * A model turn could not be journaled, or its recorded data does not describe the request + * being issued. + * + *

+ * This is not a model failure. An engine that converts caught model exceptions into + * user-visible error messages must rethrow this type instead: continuing would let tool + * dispatch proceed on a turn whose plan was never recorded. + */ +public class ModelTurnJournalException extends IllegalStateException { + + private static final long serialVersionUID = 1L; + + public ModelTurnJournalException(String message) { + super(message); + } + + public ModelTurnJournalException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java new file mode 100644 index 000000000..37191fae6 --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -0,0 +1,262 @@ +/* + * Copyright 2025-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.durable; + +import io.github.agentic.ai.api.chat.ChatRequest; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.api.chat.ToolDefinition; +import io.github.agentic.ai.runtime.execution.run.RunEntry; +import io.github.agentic.ai.runtime.execution.run.RunJournal; +import io.github.agentic.ai.runtime.execution.run.RunPayload; +import io.github.agentic.ai.runtime.execution.run.RunStoreConflictException; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static java.util.Objects.requireNonNull; + +/** + * Records the non-deterministic part of a model turn and replays it on recovery. + * + *

+ * This component is engine-neutral: it knows the neutral chat contracts and the journal, + * not graphs, streams, checkpoints or schedulers. An engine supplies the stream identity of + * its invocation and the ordinal of the model call, then records streamed text as it + * arrives and commits the terminal response before dispatching any tool. + * + *

+ * A turn is identified by the stream, the agent name and an engine-supplied ordinal. The + * recorder deliberately does not derive the ordinal from the conversation: summarisation + * and history trimming legitimately shrink the message list, so any count over it can move + * backwards and collide with an earlier turn. See {@link #begin(String, long)} for what the + * engine must guarantee. Two agents sharing one stream must not share a name; when they + * do, the request fingerprint rejects the mismatch instead of replaying another agent's + * response. + * + *

+ * The request fingerprint is taken from the request the provider actually receives, after + * every request-mutating interceptor and hook. A component that rewrites the request + * non-deterministically - for example summarisation that calls a model to produce the + * summary - can rebuild a different request for the same turn after a restart. Such a turn + * fails closed with a conflict rather than replaying a response produced for other input; + * it is not recovered automatically. Applications that need automatic recovery across such + * a rewrite must make the rewrite deterministic or disable it while journaling. + * + *

+ * Entry ids of one turn: + *

+ * + *

+ * An interrupted turn is never re-asked: a recorded request without a recorded response is + * evidence that a call was already issued, and repeating it would make cost and outcome + * depend on how often a worker restarted. Budgeted re-asking belongs to the retry path. + */ +public final class ModelTurnRecorder { + + private final RunJournal journal; + + private final String agentName; + + private ModelTurnRecorder(RunJournal journal, String agentName) { + this.journal = requireNonNull(journal, "journal cannot be null"); + this.agentName = requireNonNull(agentName, "agentName cannot be null"); + } + + /** Builds a recorder for one agent, or returns {@code null} when journaling is off. */ + public static ModelTurnRecorder of(RunJournal journal, String agentName) { + return journal == null ? null : new ModelTurnRecorder(journal, agentName); + } + + /** + * One logical model call. + * @param streamId the engine's durable stream identity, for example a graph thread id or + * an agent run id + * @param turnId the agent and turn identity within that stream + */ + public record Turn(String streamId, String turnId) { + + public Turn { + requireNonNull(streamId, "streamId cannot be null"); + requireNonNull(turnId, "turnId cannot be null"); + } + + } + + /** + * Resolves the turn this model call belongs to. + * + *

+ * The engine owns the ordinal and must guarantee three properties: + *

+ * A counter kept in persisted engine state and advanced in the same update that + * publishes the model result satisfies all three. + * @param streamId the engine's stream identity; an engine without one cannot journal + * @param ordinal the engine's monotonic model-call ordinal within the stream + * @throws IllegalArgumentException when the stream identity is blank or the ordinal is + * negative + */ + public Turn begin(String streamId, long ordinal) { + if (streamId == null || streamId.isBlank()) { + throw new IllegalArgumentException("Journal persistence requires a stream identity"); + } + if (ordinal < 0) { + throw new IllegalArgumentException("Model-call ordinal must not be negative: " + ordinal); + } + return new Turn(streamId, agentName + ":" + ordinal); + } + + /** + * Returns the recorded response of this turn when the request is the one that produced + * it. + * + *

+ * A recorded request whose fingerprint differs fails closed: replaying it would hand the + * engine a plan the model never produced for this input. This is also how a + * non-deterministic request rewrite surfaces after a restart; see the class + * documentation. A recorded request without a recorded response was interrupted after a + * provider call was issued and also fails closed, because the outcome is unknown rather + * than missing. + * @param turn the turn resolved by {@link #begin(String, long)} + * @param request the request about to be issued + * @return the recorded response, or empty when the model has not been called yet + * @throws ModelTurnJournalException when the recorded request differs, or when a turn + * was issued but its response was never recorded + */ + public Optional replay(Turn turn, ChatRequest request) { + Optional recorded = journal.find(turn.streamId(), turn.turnId() + "#req"); + if (recorded.isPresent()) { + String hash = ModelTurnCodec.decodeRequestHash(recorded.get().payload().json()); + if (!hash.equals(ModelTurnCodec.requestHash(request))) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + + " was recorded for a different request; refusing to replay it. This happens when the" + + " request is rewritten non-deterministically, for example by summarisation, or when" + + " the engine reuses an ordinal", + new RunStoreConflictException( + "Journal entry id identifies different data: " + turn.streamId() + "/" + turn.turnId())); + } + } + Optional response = journal.find(turn.streamId(), turn.turnId() + "#resp"); + if (response.isPresent()) { + return response.map(entry -> ModelTurnCodec.decodeResponse(entry.payload().json())); + } + if (recorded.isPresent()) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + + " was issued but its response was never recorded; the turn cannot be replayed automatically"); + } + return Optional.empty(); + } + + /** Records the request fingerprint before the provider call is issued. */ + public void recordRequest(Turn turn, ChatRequest request) { + append(turn, List.of(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, turn.turnId() + "#req", + RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.REQUEST_TYPE, + ModelTurnCodec.encodeRequest(request))))); + } + + /** + * Records the terminal response and, in the same atomic append, the tool plan derived + * from it. A failure here leaves no dispatchable plan behind, so the caller must treat + * it as a failure of the turn and must not dispatch the plan's tools. + * @param turn the turn being committed + * @param response the terminal response the engine is about to use + * @param tools the tool definitions that were disclosed to the model, used to pin each + * planned task to the schema the model saw + */ + public void commit(Turn turn, ChatResponse response, Iterable tools) { + List entries = new ArrayList<>(); + entries.add(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, + new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.RESPONSE_TYPE, + ModelTurnCodec.encodeResponse(response)))); + Map byName = new HashMap<>(); + if (tools != null) { + tools.forEach(definition -> byName.put(definition.name(), definition)); + } + int ordinal = 0; + for (ToolCall call : response.toolCalls()) { + entries.add(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, turn.turnId() + "#task." + ordinal, + RunEntry.Kind.TASK_CALL, + new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.PLAN_TYPE, + ModelTurnCodec.encodeToolPlan(call, byName.get(call.name()), ordinal)))); + ordinal++; + } + append(turn, entries); + } + + /** + * Records text received while a response is still streaming. + * + *

+ * These entries are diagnostic: they explain how far a lost stream got. They are never + * a source of truth, because a partial response is not a model result and must not + * trigger tool execution. The attempt identifier keeps text from an abandoned attempt + * from colliding with a later one, and is stable for every chunk of one attempt so the + * recorded text stays ordered. + * @param attempt the attempt token obtained from {@link #newAttempt()} + * @param seq the chunk ordinal within this attempt + * @param text the text delta received + */ + public void recordDelta(Turn turn, String attempt, int seq, String text) { + if (text == null || text.isEmpty()) { + return; + } + append(turn, List.of(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, + turn.turnId() + "#progress." + attempt + "#delta." + seq, RunEntry.Kind.EVENT, + new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.DELTA_TYPE, + ModelTurnCodec.encodeDelta(text))))); + } + + /** + * A fresh identifier for one streaming attempt. Every subscription that starts a new + * provider call must use its own token, so an abandoned attempt cannot overwrite the + * text of the attempt that replaced it. + */ + public static String newAttempt() { + return UUID.randomUUID().toString(); + } + + private void append(Turn turn, List entries) { + try { + journal.append(turn.streamId(), entries); + } + catch (ModelTurnJournalException already) { + throw already; + } + catch (RuntimeException failure) { + throw new ModelTurnJournalException("Failed to journal model turn " + turn.turnId(), failure); + } + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java new file mode 100644 index 000000000..422e8f6eb --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025-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. + */ + +/** + * Engine-neutral journaling of model turns. + * + *

+ * The non-deterministic result of a model call — the assistant message, its tool calls, + * finish reason and usage — cannot be regenerated. These types record it, together with a + * stable identity for every planned tool call, before the engine dispatches tools. A + * recovery then replays the recorded plan instead of asking the model again. + * + *

+ * The contract deliberately knows no graph, session, scheduler or streaming library: it + * depends on the neutral chat contracts and on {@code io.github.agentic.ai.runtime.execution.run}. + * Graph-backed and graph-independent engines share it, and each supplies its own stream + * identity. + * + *

+ * The engine is responsible for one ordering rule: {@link io.github.agentic.ai.agent.durable.ModelTurnRecorder#commit} + * must succeed before the first tool of that turn is dispatched. A failed commit means the + * plan was never recorded, so no tool may run. + */ +package io.github.agentic.ai.agent.durable; diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java new file mode 100644 index 000000000..331012b95 --- /dev/null +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -0,0 +1,313 @@ +/* + * Copyright 2025-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.durable; + +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatRequest; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.ChatRole; +import io.github.agentic.ai.api.chat.FinishReason; +import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.api.chat.ToolDefinition; +import io.github.agentic.ai.runtime.execution.run.MemoryRunJournal; +import io.github.agentic.ai.runtime.execution.run.RunEntry; +import io.github.agentic.ai.runtime.execution.run.RunJournal; +import io.github.agentic.ai.runtime.execution.run.RunStoreConflictException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** The neutral model-turn contract, exercised without any graph or streaming engine. */ +@Timeout(10) +class ModelTurnRecorderTest { + + private static final String STREAM = "stream-1"; + + private static final List FIRST_TURN = List.of(ChatMessage.user("search for argi")); + + private static final List TOOLS = List.of( + new ToolDefinition("search", "search the web", "{\"type\":\"object\"}")); + + @Test + void recordsOnlyTheRequestFingerprintBeforeTheCall() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + + recorder.recordRequest(turn, request(FIRST_TURN)); + + RunEntry recorded = journal.find(STREAM, "agent:0#req").orElseThrow(); + assertThat(recorded.kind()).isEqualTo(RunEntry.Kind.MODEL_CALL); + assertThat(recorded.payload().type()).isEqualTo(ModelTurnCodec.REQUEST_TYPE); + // The request body is never persisted: only the fingerprint is. + assertThat(recorded.payload().json()).contains("requestHash").doesNotContain("search for argi"); + } + + @Test + void equivalentToolArgumentsProduceTheSameHashAndRecord() { + assertThat(ModelTurnCodec.canonicalArguments("{\"q\":\"x\",\"p\":\"y\"}")).isEqualTo("{\"p\":\"y\",\"q\":\"x\"}"); + assertThat(ModelTurnCodec.canonicalArguments("{ \"p\" : \"y\", \"q\" : \"x\" }")) + .isEqualTo("{\"p\":\"y\",\"q\":\"x\"}"); + + String first = ModelTurnCodec.encodeToolPlan(new ToolCall("call-a", "search", "{\"q\":\"x\",\"p\":\"y\"}"), + TOOLS.get(0), 0); + String second = ModelTurnCodec.encodeToolPlan( + new ToolCall("call-b", "search", "{ \"p\" : \"y\", \"q\" : \"x\" }"), TOOLS.get(0), 0); + + // Formatting and the provider's call id must not change the recorded plan. + assertThat(second).isEqualTo(first); + assertThat(first).contains("\"arguments\":\"{\\\"p\\\":\\\"y\\\",\\\"q\\\":\\\"x\\\"}\""); + } + + @Test + void arrayOrderAndMalformedArgumentsArePreserved() { + // Array order is positional, so it carries meaning and must not be sorted. + assertThat(ModelTurnCodec.canonicalArguments("{\"tags\":[\"b\",\"a\"]}")) + .isEqualTo("{\"tags\":[\"b\",\"a\"]}"); + // Argument validation is opt-in, so an unparseable argument must pass through. + assertThat(ModelTurnCodec.canonicalArguments("not json at all")).isEqualTo("not json at all"); + assertThat(ModelTurnCodec.canonicalArguments("{}")).isEqualTo("{}"); + assertThat(ModelTurnCodec.canonicalArguments(null)).isNull(); + } + + @Test + void equivalentToolArgumentsInHistoryDoNotChangeTheRequestFingerprint() { + ChatMessage assistant = new ChatMessage(ChatRole.ASSISTANT, List.of(), + List.of(new ToolCall("call-a", "search", "{\"q\":\"x\",\"p\":\"y\"}")), null, null, Map.of()); + ChatMessage reformatted = new ChatMessage(ChatRole.ASSISTANT, List.of(), + List.of(new ToolCall("call-a", "search", "{ \"p\":\"y\", \"q\":\"x\" }")), null, null, Map.of()); + List first = List.of(ChatMessage.user("go"), assistant); + List second = List.of(ChatMessage.user("go"), reformatted); + + assertThat(ModelTurnCodec.requestHash(request(second))).isEqualTo(ModelTurnCodec.requestHash(request(first))); + } + + @Test + void commitsTheResponseAndItsPlanInOneAppend() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + + recorder.commit(turn, toolCallingResponse("provider-call-1"), + List.of(TOOLS.get(0))); + + assertThat(journal.entryIds(STREAM)).containsExactly("agent:0#resp", "agent:0#task.0"); + RunEntry plan = journal.find(STREAM, "agent:0#task.0").orElseThrow(); + assertThat(plan.kind()).isEqualTo(RunEntry.Kind.TASK_CALL); + assertThat(plan.payload().type()).isEqualTo(ModelTurnCodec.PLAN_TYPE); + assertThat(plan.payload().json()).contains("\"toolName\":\"search\"", "\"ordinal\":0", "\"argumentsHash\""); + // The provider's call id is protocol correlation, never task identity. + assertThat(plan.payload().json()).doesNotContain("provider-call-1"); + } + + @Test + void replaysTheRecordedResponseAndPlanWithoutCallingTheModel() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + recorder.recordRequest(turn, request(FIRST_TURN)); + recorder.commit(turn, toolCallingResponse("provider-call-1"), TOOLS); + + Optional replayed = recorder.replay(recorder.begin(STREAM, 0), request(FIRST_TURN)); + + assertThat(replayed).isPresent(); + assertThat(replayed.orElseThrow().toolCalls()).extracting(ToolCall::name).containsExactly("search"); + assertThat(replayed.orElseThrow().toolCalls()).extracting(ToolCall::arguments) + .containsExactly("{\"q\":\"argi\"}"); + } + + @Test + void rejectsReplayWhenTheRecordedRequestDiffers() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + recorder.recordRequest(recorder.begin(STREAM, 0), request(FIRST_TURN)); + recorder.commit(recorder.begin(STREAM, 0), toolCallingResponse("provider-call-1"), TOOLS); + + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + List different = List.of(ChatMessage.user("a different instruction")); + + assertThatThrownBy(() -> recorder.replay(turn, request(different))) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("different request") + .hasCauseInstanceOf(RunStoreConflictException.class); + } + + @Test + void anInterruptedTurnFailsClosedInsteadOfAskingAgain() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + recorder.recordRequest(recorder.begin(STREAM, 0), request(FIRST_TURN)); + + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN))) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("never recorded"); + assertThat(journal.find(STREAM, "agent:0#resp")).isEmpty(); + } + + @Test + void ajournalFailureSurfacesAsAJournalFailure() { + RunJournal failing = new RunJournal() { + @Override + public Optional find(String streamId, String entryId) { + return Optional.empty(); + } + + @Override + public List entries(String streamId) { + return List.of(); + } + + @Override + public void append(String streamId, List entries) { + throw new IllegalStateException("journal unavailable"); + } + }; + ModelTurnRecorder recorder = ModelTurnRecorder.of(failing, "agent"); + + assertThatThrownBy(() -> recorder.recordRequest(recorder.begin(STREAM, 0), request(FIRST_TURN))) + .isInstanceOf(ModelTurnJournalException.class) + .hasRootCauseMessage("journal unavailable"); + } + + @Test + void aCommittedTurnAddsNoSecondPlanWhenTheProviderRenamesTheCallId() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + recorder.recordRequest(turn, request(FIRST_TURN)); + recorder.commit(turn, toolCallingResponse("provider-call-1"), TOOLS); + + // Recovery replays; it must not append a new task for the renamed call id. + recorder.replay(recorder.begin(STREAM, 0), request(FIRST_TURN)); + + assertThat(journal.entryIds(STREAM)).containsExactly("agent:0#req", "agent:0#resp", "agent:0#task.0"); + } + + @Test + void streamedTextIsRecordedUnderOneStableAttempt() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + String attempt = ModelTurnRecorder.newAttempt(); + + recorder.recordDelta(turn, attempt, 0, "he"); + recorder.recordDelta(turn, attempt, 1, "llo"); + recorder.recordDelta(turn, attempt, 2, ""); + + assertThat(journal.entryIds(STREAM)).containsExactly("agent:0#progress." + attempt + "#delta.0", + "agent:0#progress." + attempt + "#delta.1"); + // Progress is diagnostic only: it never produces a response or a plan. + assertThat(journal.find(STREAM, "agent:0#resp")).isEmpty(); + assertThat(journal.find(STREAM, "agent:0#task.0")).isEmpty(); + } + + @Test + void turnIdentityComesFromTheEngineOrdinalNotFromTheConversation() { + ModelTurnRecorder recorder = ModelTurnRecorder.of(new MemoryRunJournal(), "agent"); + + assertThat(recorder.begin(STREAM, 0).turnId()).isEqualTo("agent:0"); + assertThat(recorder.begin(STREAM, 7).turnId()).isEqualTo("agent:7"); + assertThat(recorder.begin(STREAM, 42).turnId()).isEqualTo("agent:42"); + } + + /** + * Regression guard for the defect that made summarisation fatal: a count over the + * conversation shrinks when history is summarised or trimmed, so the next call reused + * an ordinal that an earlier turn already owned and every later turn conflicted. + */ + @Test + void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + for (long ordinal = 0; ordinal < 3; ordinal++) { + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, ordinal); + recorder.recordRequest(turn, request(List.of(ChatMessage.user("question " + ordinal)))); + recorder.commit(turn, toolCallingResponse("call-" + ordinal), TOOLS); + } + + // Summarisation replaced the history with a summary plus a short tail, so the + // conversation now holds fewer assistant messages than before. The engine keeps + // counting, so the next turn is new instead of colliding with turns 0..2. + List summarisedHistory = List.of(ChatMessage.system("summary of earlier turns"), + ChatMessage.user("question 3")); + ModelTurnRecorder.Turn next = recorder.begin(STREAM, 3); + + assertThat(next.turnId()).isEqualTo("agent:3"); + assertThat(recorder.replay(next, request(summarisedHistory))).isEmpty(); + recorder.recordRequest(next, request(summarisedHistory)); + assertThat(journal.find(STREAM, "agent:3#req")).isPresent(); + } + + /** + * Documented limitation: a request rewritten non-deterministically between the original + * call and a restart - for example by a summarisation hook that asks a model for the + * summary - yields a different fingerprint for the same turn. The turn must fail closed + * rather than replay a response that was produced for other input. + */ + @Test + void aNonDeterministicRewriteOfTheSameTurnFailsClosed() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 5); + recorder.recordRequest(turn, request(List.of(ChatMessage.system("summary, first attempt"), + ChatMessage.user("question")))); + recorder.commit(turn, toolCallingResponse("call-1"), TOOLS); + + // After a restart the same hook produced a different summary for the same turn. + List rebuilt = List.of(ChatMessage.system("summary, second attempt"), + ChatMessage.user("question")); + + assertThatThrownBy(() -> recorder.replay(recorder.begin(STREAM, 5), request(rebuilt))) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("summarisation"); + } + + @Test + void aMissingStreamIdentityOrNegativeOrdinalIsRejected() { + ModelTurnRecorder recorder = ModelTurnRecorder.of(new MemoryRunJournal(), "agent"); + + assertThatThrownBy(() -> recorder.begin(" ", 0)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("stream identity"); + assertThatThrownBy(() -> recorder.begin(null, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> recorder.begin(STREAM, -1)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not be negative"); + } + + @Test + void aDisabledJournalYieldsNoRecorder() { + assertThat(ModelTurnRecorder.of(null, "agent")).isNull(); + } + + private static ChatRequest request(List messages) { + return new ChatRequest(messages, TOOLS, null, Map.of()); + } + + private static ChatResponse toolCallingResponse(String callId) { + ToolCall call = new ToolCall(callId, "search", "{\"q\":\"argi\"}"); + return new ChatResponse("response", "test-model", List.of(ChatMessage.assistant("", List.of(call))), + List.of(call), FinishReason.TOOL_CALLS, null, Map.of()); + } + +} diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java new file mode 100644 index 000000000..10960f789 --- /dev/null +++ b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java @@ -0,0 +1,95 @@ +/* + * Copyright 2025-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.runtime.execution.run; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static java.util.Objects.requireNonNull; + +/** + * Single-process reference journal. A shared monitor makes each append atomic and + * validate-before-publish: every entry is checked against the recorded stream before any + * of them becomes visible, so a rejected batch leaves the journal unchanged. + * + *

+ * This implementation keeps data only in memory. It is a test and development backend, + * not a durability guarantee; a journal that must survive a JVM must be backed by + * storage and verified against {@code RunJournalContractTest}. + */ +public final class MemoryRunJournal implements RunJournal { + + private final Map> streams = new HashMap<>(); + + @Override + public synchronized Optional find(String streamId, String entryId) { + RunValidation.text(streamId, "streamId"); + RunValidation.text(entryId, "entryId"); + Map stream = streams.get(streamId); + return stream == null ? Optional.empty() : Optional.ofNullable(stream.get(entryId)); + } + + @Override + public synchronized List entries(String streamId) { + RunValidation.text(streamId, "streamId"); + Map stream = streams.get(streamId); + return stream == null ? List.of() : List.copyOf(stream.values()); + } + + @Override + public synchronized void append(String streamId, List entries) { + RunValidation.text(streamId, "streamId"); + requireNonNull(entries, "entries cannot be null"); + List batch = List.copyOf(entries); + for (RunEntry entry : batch) { + requireNonNull(entry, "entries cannot contain null"); + } + Map stream = streams.get(streamId); + // Validate the whole batch first: no partial write may survive a conflict. + for (RunEntry entry : batch) { + RunEntry existing = stream == null ? null : stream.get(entry.id()); + if (existing != null && !existing.equals(entry)) { + throw new RunStoreConflictException( + "Journal entry id identifies different data: " + streamId + "/" + entry.id()); + } + } + if (stream == null) { + stream = new LinkedHashMap<>(); + streams.put(streamId, stream); + } + for (RunEntry entry : batch) { + stream.putIfAbsent(entry.id(), entry); + } + } + + /** Entry count of one stream; a convenience for assertions and diagnostics. */ + public synchronized int size(String streamId) { + Map stream = streams.get(streamId); + return stream == null ? 0 : stream.size(); + } + + /** All entry ids of one stream in append order. */ + public synchronized List entryIds(String streamId) { + Map stream = streams.get(streamId); + return stream == null ? List.of() : new ArrayList<>(stream.keySet()); + } + +} diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java new file mode 100644 index 000000000..c02d61147 --- /dev/null +++ b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025-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.runtime.execution.run; + +import java.util.List; +import java.util.Optional; + +/** + * Append-only journal for data that cannot be regenerated: model requests and the + * responses and tool plans derived from them. + * + *

+ * This is deliberately narrower than {@link RunStore}. A journal write carries no lease, + * no run state transition and no revision, because it is not a run commit: it records + * what already happened so a recovery can reuse it instead of asking a model again. Use + * {@link RunStore#commit} when checkpoint, task and journal data must advance together + * under an owner fence. + * + *

+ * Ids are caller-assigned and stable across JVMs. Re-appending an id with identical data + * is a no-op; re-appending it with different data is a conflict and nothing is written. + * + * @see RunEntry + */ +public interface RunJournal { + + /** + * Reads one entry of one stream. + * @param streamId caller-owned stream identity, for example a graph thread id + * @param entryId stable entry identity within that stream + * @return the recorded entry, or empty when it was never appended + */ + Optional find(String streamId, String entryId); + + /** Every entry of {@code streamId} in append order; empty when the stream is unknown. */ + List entries(String streamId); + + /** + * Atomically appends every entry or none of them. + * + *

+ * All data is validated before anything is published, so a conflict or a rejected + * payload leaves the journal unchanged. A duplicate id whose data equals the recorded + * entry is accepted, which makes a retried append after an unknown outcome safe. + * @param streamId caller-owned stream identity + * @param entries entries to append in order + * @throws RunStoreConflictException when an id is already recorded with other data + * @throws IllegalArgumentException when an entry is invalid + */ + void append(String streamId, List entries); + +} diff --git a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java new file mode 100644 index 000000000..39e0d6ff5 --- /dev/null +++ b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025-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.runtime.execution.run; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Contract and reference-backend specifics for {@link MemoryRunJournal}. */ +class MemoryRunJournalTest extends RunJournalContractTest { + + @Override + protected RunJournal newJournal() { + return new MemoryRunJournal(); + } + + @Test + void exposesInsertionOrderedIdsForDiagnostics() { + MemoryRunJournal memory = new MemoryRunJournal(); + memory.append("thread", List.of(entry("a"), entry("b"))); + memory.append("thread", List.of(entry("c"))); + assertThat(memory.entryIds("thread")).containsExactly("a", "b", "c"); + assertThat(memory.size("thread")).isEqualTo(3); + assertThat(memory.size("unknown")).isZero(); + assertThat(memory.entryIds("unknown")).isEmpty(); + } + + @Test + void returnsAnImmutableSnapshotOfAppendedEntries() { + MemoryRunJournal memory = new MemoryRunJournal(); + memory.append("thread", List.of(entry("a"))); + List snapshot = memory.entries("thread"); + memory.append("thread", List.of(entry("b"))); + assertThat(snapshot).hasSize(1); + } + +} diff --git a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java new file mode 100644 index 000000000..db2810b6d --- /dev/null +++ b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025-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.runtime.execution.run; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Reusable semantic contract for {@link RunJournal} adapters. Backend suites should + * extend this fixture and additionally verify restart persistence and atomic visibility + * under real storage. + */ +public abstract class RunJournalContractTest { + + protected RunJournal journal; + + protected abstract RunJournal newJournal(); + + @BeforeEach + void setUp() { + journal = newJournal(); + } + + @Test + void unknownStreamAndEntryReadAsAbsent() { + assertThat(journal.find("thread", "a")).isEmpty(); + assertThat(journal.entries("thread")).isEmpty(); + } + + @Test + void preservesAppendOrderAcrossBatches() { + journal.append("thread", List.of(entry("a"), entry("b"))); + journal.append("thread", List.of(entry("c"))); + assertThat(journal.entries("thread")).extracting(RunEntry::id).containsExactly("a", "b", "c"); + } + + @Test + void reappendingIdenticalDataIsIdempotent() { + RunEntry first = entry("a"); + journal.append("thread", List.of(first)); + journal.append("thread", List.of(entry("a"))); + assertThat(journal.entries("thread")).containsExactly(first); + } + + @Test + void rejectsSameIdWithDifferentDataWithoutPartialWrite() { + journal.append("thread", List.of(entry("a"))); + assertThatThrownBy(() -> journal.append("thread", List.of(entry("b"), entry("a", "{\"value\":2}")))) + .isInstanceOf(RunStoreConflictException.class); + assertThat(journal.find("thread", "b")).isEmpty(); + assertThat(journal.entries("thread")).hasSize(1); + } + + @Test + void separatesStreamsWithIndependentIds() { + journal.append("one", List.of(entry("a", "{\"value\":1}"))); + journal.append("two", List.of(entry("a", "{\"value\":2}"))); + assertThat(journal.find("one", "a")).hasValueSatisfying( + value -> assertThat(value.payload().json()).contains("\"value\":1")); + assertThat(journal.find("two", "a")).hasValueSatisfying( + value -> assertThat(value.payload().json()).contains("\"value\":2")); + } + + @Test + void rejectsBlankIdentitiesAndNullEntries() { + assertThatThrownBy(() -> journal.append(" ", List.of(entry("a")))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> journal.find("thread", " ")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> journal.entries(" ")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> journal.append("thread", List.of((RunEntry) null))) + .isInstanceOf(NullPointerException.class); + } + + protected static RunEntry entry(String id) { + return entry(id, "{\"value\":1}"); + } + + protected static RunEntry entry(String id, String json) { + return new RunEntry(1, id, RunEntry.Kind.MODEL_CALL, new RunPayload(1, "test.payload", json)); + } + +} From 76c5a5dbfb5226f01e7a23a1b17570ed09c47c38 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Thu, 29 Oct 2026 16:54:10 +0800 Subject: [PATCH 02/12] refactor(agent): use caller-supplied model turn identities --- .../ai/agent/durable/ModelTurnCodec.java | 587 ++++++++++-------- .../ai/agent/durable/ModelTurnRecorder.java | 76 +-- .../ai/agent/durable/package-info.java | 5 +- .../agent/durable/ModelTurnRecorderTest.java | 101 +-- 4 files changed, 399 insertions(+), 370 deletions(-) diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java index 668f7916d..0711f5976 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java @@ -18,295 +18,358 @@ import io.github.agentic.ai.api.chat.ChatMessage; import io.github.agentic.ai.api.chat.ChatRequest; import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.ChatRole; import io.github.agentic.ai.api.chat.ContentPart; +import io.github.agentic.ai.api.chat.FinishReason; import io.github.agentic.ai.api.chat.GenerationOptions; +import io.github.agentic.ai.api.chat.ResponseFormat; import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.api.chat.ToolChoice; import io.github.agentic.ai.api.chat.ToolDefinition; +import io.github.agentic.ai.api.chat.Usage; import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.StreamReadFeature; -import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.json.JsonMapper; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.TreeSet; +import java.util.TreeMap; /** - * Version-tagged JSON payloads for model-turn journal entries. - * - *

- * The neutral chat contracts are bound by their declared record components, not by a - * hand-written field-by-field codec: the payload and the type stay described in one place, - * and a codec bug cannot desynchronise the write and read paths. What is written by hand - * is only where the recorded shape must deliberately differ from the type: - *

+ * Explicit, version-tagged JSON payloads for model-turn journal entries. * *

- * No Java type information is written: this mapper never enables default typing, so a - * payload stays readable by any process and after a class moves. Unknown fields are - * ignored on read so a newer writer can add fields without invalidating old records, and - * property and map-key ordering is pinned so equal data always produces equal JSON - which - * is what makes {@link #requestHash(ChatRequest)} stable. + * This is deliberately not a Java object serializer: a recorded response must stay + * readable after a class changes, so every field is written by name and unknown fields + * are ignored on read. The payload types below are the schema identifiers carried in + * {@code RunPayload#type()}; they are stable data, never Java class names. * *

- * Request bodies are never journaled. Responses and tool arguments are, because replaying - * them is the purpose of the journal; applications apply their data-protection policy to - * the journal backend. + * Request bodies are never journaled. A request is reduced to a SHA-256 fingerprint of its + * canonical form, which is enough to prove a recovered turn is the same request without + * persisting prompts that may contain sensitive data. Responses and tool arguments are + * recorded, because replaying them is the purpose of the journal; applications apply + * their data-protection policy to the journal backend. */ public final class ModelTurnCodec { - /** Version of every payload schema defined here, carried by {@code RunPayload}. */ - public static final int SCHEMA_VERSION = 1; - - /** {@code MODEL_CALL}: the request fingerprint, written before the provider call. */ - public static final String REQUEST_TYPE = "argi.model-request"; - - /** {@code MODEL_CALL}: the complete terminal response. */ - public static final String RESPONSE_TYPE = "argi.model-response"; - - /** {@code TASK_CALL}: one planned tool call, committed with its response. */ - public static final String PLAN_TYPE = "argi.tool-plan"; - - /** {@code EVENT}: streamed text received before the terminal response. */ - public static final String DELTA_TYPE = "argi.model-delta"; - - /** - * Metadata that belongs to one process run: protocol markers and handles into - * in-memory tool-binding snapshots. A recovering invocation re-derives these, so - * recording them would only replay stale state - and a snapshot id that changes per - * process would change the request fingerprint across a restart. - */ - private static final Set PROCESS_LOCAL_METADATA = Set.of("argiFinalResponse", "argiTextDelta", - "argi.toolBindingSnapshot", "argi.toolBindingSelection", "argi.dynamicToolBindings", "argiModelFailure"); - - private static final JsonMapper MAPPER = JsonMapper.builder() - .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) - .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) - .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) - .build(); - - private ModelTurnCodec() { - } - - /** - * Canonical fingerprint of the request a provider actually receives: ordered messages, - * disclosed tool definitions and effective generation options. - * - *

- * {@link ChatRequest#attributes()} is deliberately excluded. It is a transport escape - * hatch whose meaning differs per adapter (Spring AI passes it to advisor parameters, - * LangChain4j to listener attributes, Google ADK only checks emptiness), and in the - * graph-independent engine it carries the whole mutable agent state. Hashing it would - * bind request identity to adapter internals and to state that changes for reasons - * unrelated to the request. An application whose adapters derive generation semantics - * from attributes must not rely on replay across differing attribute values. - */ - public static String requestHash(ChatRequest request) { - Objects.requireNonNull(request, "request cannot be null"); - RequestFingerprint fingerprint = new RequestFingerprint(sanitize(request.messages()), request.tools(), - request.options()); - return sha256(write(fingerprint)); - } - - /** The {@link #REQUEST_TYPE} payload: the fingerprint only, never the request body. */ - public static String encodeRequest(ChatRequest request) { - return write(new RequestRecord(requestHash(request))); - } - - /** Reads the fingerprint recorded in a {@link #REQUEST_TYPE} payload. */ - public static String decodeRequestHash(String json) { - return read(json, RequestRecord.class).requestHash(); - } - - /** The {@link #RESPONSE_TYPE} payload. */ - public static String encodeResponse(ChatResponse response) { - return write(sanitize(Objects.requireNonNull(response, "response cannot be null"))); - } - - public static ChatResponse decodeResponse(String json) { - return read(json, ChatResponse.class); - } - - /** The {@link #DELTA_TYPE} payload. */ - public static String encodeDelta(String text) { - return write(new Delta(text)); - } - - /** - * The {@link #PLAN_TYPE} payload. The tool-call id assigned by the provider is - * deliberately absent: it correlates protocol messages, it is not a task identity. - * - *

- * Arguments are stored and hashed in canonical form, so two semantically equal - * invocations that differ only in whitespace or object-key order produce the same - * record and the same hash. The hash is intended to become an idempotency key for - * external effects, which only holds if formatting cannot change it. - */ - public static String encodeToolPlan(ToolCall call, ToolDefinition definition, int ordinal) { - String arguments = canonicalArguments(call.arguments()); - return write(new ToolPlan(ordinal, call.name(), definition == null ? null : definitionVersion(definition), - sha256(arguments), arguments)); - } - - /** - * Canonical form of a tool-argument JSON string: object keys sorted recursively, - * compact output. - * - *

- * Keys are reordered explicitly rather than through a mapper feature: the mapper's - * ordering settings apply to POJO properties and {@code Map} entries, not to an - * {@code ObjectNode}, which preserves insertion order. Only objects are reordered. - * Array order carries meaning (argument lists are positional) and scalars have no order. - * Malformed JSON is returned unchanged rather than rejected: argument validation is - * opt-in, so a provider may legitimately produce arguments this codec cannot parse, and - * that must not fail a turn that would otherwise dispatch. - */ - public static String canonicalArguments(String arguments) { - if (arguments == null || arguments.isBlank()) { - return arguments; - } - try { - JsonNode tree = MAPPER.readTree(arguments); - return tree == null || !tree.isContainerNode() ? arguments : write(sortKeys(tree)); - } - catch (JsonProcessingException malformed) { - return arguments; - } - } - - private static JsonNode sortKeys(JsonNode node) { - if (node.isObject()) { - ObjectNode sorted = MAPPER.createObjectNode(); - TreeSet keys = new TreeSet<>(); - node.fieldNames().forEachRemaining(keys::add); - keys.forEach(key -> sorted.set(key, sortKeys(node.get(key)))); - return sorted; - } - if (node.isArray()) { - ArrayNode values = MAPPER.createArrayNode(); - node.forEach(child -> values.add(sortKeys(child))); - return values; - } - return node; - } - - /** Stable identity of one tool definition; a changed schema must not reuse a task id. */ - public static String definitionVersion(ToolDefinition definition) { - return sha256(write(new DefinitionRecord(definition.name(), definition.description(), - definition.inputSchema()))); - } - - private static List sanitize(List messages) { - return messages == null ? List.of() : messages.stream().map(ModelTurnCodec::sanitize).toList(); - } - - private static ChatResponse sanitize(ChatResponse response) { - return new ChatResponse(response.id(), response.model(), sanitize(response.messages()), response.toolCalls(), - response.finishReason(), response.usage(), sanitize(response.metadata())); - } - - private static ChatMessage sanitize(ChatMessage message) { - List content = message.content() - .stream() - .map(part -> new ContentPart(part.type(), part.text(), part.mimeType(), part.data(), - sanitize(part.metadata()))) - .toList(); - // Tool arguments are canonicalised here too: an assistant message replaying earlier - // calls must fingerprint identically no matter how the provider formatted them. - List toolCalls = message.toolCalls() - .stream() - .map(call -> new ToolCall(call.id(), call.name(), canonicalArguments(call.arguments()))) - .toList(); - return new ChatMessage(message.role(), content, toolCalls, message.toolCallId(), message.toolName(), - sanitize(message.metadata())); - } - - private static Map sanitize(Map metadata) { - if (metadata == null || metadata.isEmpty()) { - return Map.of(); - } - Map retained = new LinkedHashMap<>(); - metadata.forEach((key, value) -> { - if (key != null && value != null && !PROCESS_LOCAL_METADATA.contains(key)) { - retained.put(key, value); - } - }); - return retained; - } - - private static T read(String json, Class type) { - try { - return MAPPER.readValue(Objects.requireNonNull(json, "journal payload cannot be null"), type); - } - catch (JsonProcessingException ex) { - throw new ModelTurnJournalException("Cannot decode journal payload as " + type.getSimpleName(), ex); - } - } - - private static String write(Object payload) { - try { - return MAPPER.writeValueAsString(payload); - } - catch (JsonProcessingException ex) { - throw new ModelTurnJournalException("Cannot encode journal payload " + payload.getClass().getSimpleName(), - ex); - } - } - - private static String sha256(String value) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); - } - catch (NoSuchAlgorithmException ex) { - throw new IllegalStateException("SHA-256 is required by the Java platform", ex); - } - } - - /** Stored shape of {@link #REQUEST_TYPE}: the hash only, never the request body. */ - private record RequestRecord(String requestHash) { - } - - /** - * Hashed shape of a request. Excludes {@link ChatRequest#attributes()} on purpose; see - * {@link #requestHash(ChatRequest)}. - */ - private record RequestFingerprint(List messages, List tools, - GenerationOptions options) { - } - - /** Stored shape of {@link #PLAN_TYPE}; excludes the provider's tool-call id. */ - private record ToolPlan(int ordinal, String toolName, String definitionVersion, String argumentsHash, - String arguments) { - } - - /** Hashed shape of a tool definition; a changed schema must not reuse a task id. */ - private record DefinitionRecord(String name, String description, String inputSchema) { - } - - /** Stored shape of {@link #DELTA_TYPE}. */ - private record Delta(String text) { - } + /** + * Version of every payload schema defined here. + */ + public static final int SCHEMA_VERSION = 1; + + /** + * {@code MODEL_CALL}: the request fingerprint, written before the provider call. + */ + public static final String REQUEST_TYPE = "argi.model-request"; + + /** + * {@code MODEL_CALL}: the complete terminal response. + */ + public static final String RESPONSE_TYPE = "argi.model-response"; + + /** + * {@code TASK_CALL}: one planned tool call, committed with its response. + */ + public static final String PLAN_TYPE = "argi.tool-plan"; + + /** + * {@code EVENT}: streamed text received before the terminal response. + */ + public static final String DELTA_TYPE = "argi.model-delta"; + + /** + * Metadata that belongs to one process run: protocol markers and handles into + * in-memory tool-binding snapshots. A recovering invocation re-derives these, so + * recording them would only replay stale state. + */ + private static final Set PROCESS_LOCAL_METADATA = Set.of( + "argiFinalResponse", + "argiTextDelta", + "argi.toolBindingSnapshot", + "argi.toolBindingSelection", + "argi.dynamicToolBindings", + "argiModelFailure" + ); + + private static final JsonMapper MAPPER = JsonMapper.builder().build(); + + private ModelTurnCodec() { + } + + /** + * Canonical fingerprint of the request a provider actually receives: ordered messages, + * disclosed tool definitions and effective generation options. Two requests with the + * same fingerprint are the same logical model call. + */ + public static String requestHash(ChatRequest request) { + return sha256(encodeRequestBody(Objects.requireNonNull(request, "request cannot be null"))); + } + + /** + * The {@link #REQUEST_TYPE} payload: the fingerprint only, never the request body. + */ + public static String encodeRequest(ChatRequest request) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("schemaVersion", SCHEMA_VERSION); + root.put("requestHash", requestHash(request)); + return write(root); + } + + /** + * Reads the fingerprint recorded in a {@link #REQUEST_TYPE} payload. + */ + public static String decodeRequestHash(String json) { + return text(read(json), "requestHash"); + } + + /** + * The {@link #RESPONSE_TYPE} payload. + */ + public static String encodeResponse(ChatResponse response) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("schemaVersion", SCHEMA_VERSION); + root.put("id", response.id()); + root.put("model", response.model()); + root.put("finishReason", response.finishReason() == null ? null : response.finishReason().name()); + root.set("usage", encodeUsage(response.usage())); + root.set("metadata", encodeMetadata(response.metadata())); + ArrayNode messages = root.putArray("messages"); + for (ChatMessage message : response.messages()) { + messages.add(encodeMessage(message)); + } + ArrayNode toolCalls = root.putArray("toolCalls"); + for (ToolCall call : response.toolCalls()) { + toolCalls.add(encodeToolCall(call)); + } + return write(root); + } + + public static ChatResponse decodeResponse(String json) { + JsonNode root = read(json); + List messages = new ArrayList<>(); + for (JsonNode message : root.path("messages")) { + messages.add(decodeMessage(message)); + } + List toolCalls = new ArrayList<>(); + for (JsonNode call : root.path("toolCalls")) { + toolCalls.add(decodeToolCall(call)); + } + String finishReason = text(root, "finishReason"); + return new ChatResponse(text(root, "id"), text(root, "model"), messages, toolCalls, + finishReason == null ? null : FinishReason.valueOf(finishReason), decodeUsage(root.path("usage")), + decodeMetadata(root.path("metadata"))); + } + + /** + * The {@link #DELTA_TYPE} payload. + */ + public static String encodeDelta(String text) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("schemaVersion", SCHEMA_VERSION); + root.put("text", text); + return write(root); + } + + /** + * The {@link #PLAN_TYPE} payload. The tool-call id assigned by the provider is + * deliberately absent: it correlates protocol messages, it is not a task identity. + */ + public static String encodeToolPlan(ToolCall call, ToolDefinition definition, int ordinal) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("schemaVersion", SCHEMA_VERSION); + root.put("ordinal", ordinal); + root.put("toolName", call.name()); + root.put("definitionVersion", definition == null ? null : definitionVersion(definition)); + root.put("argumentsHash", sha256(call.arguments())); + root.put("arguments", call.arguments()); + return write(root); + } + + /** + * Stable identity of one tool definition; a changed schema must not reuse a task id. + */ + public static String definitionVersion(ToolDefinition definition) { + ObjectNode root = MAPPER.createObjectNode(); + root.put("name", definition.name()); + root.put("description", definition.description()); + root.put("inputSchema", definition.inputSchema()); + return sha256(write(root)); + } + + private static String encodeRequestBody(ChatRequest request) { + ObjectNode root = MAPPER.createObjectNode(); + ArrayNode messages = root.putArray("messages"); + for (ChatMessage message : request.messages()) { + messages.add(encodeMessage(message)); + } + ArrayNode tools = root.putArray("tools"); + for (ToolDefinition definition : request.tools()) { + ObjectNode tool = tools.addObject(); + tool.put("name", definition.name()); + tool.put("description", definition.description()); + tool.put("inputSchema", definition.inputSchema()); + } + root.set("options", encodeOptions(request.options())); + return write(root); + } + + private static ObjectNode encodeMessage(ChatMessage message) { + ObjectNode node = MAPPER.createObjectNode(); + node.put("role", message.role().name()); + node.put("toolCallId", message.toolCallId()); + node.put("toolName", message.toolName()); + ArrayNode parts = node.putArray("content"); + for (ContentPart part : message.content()) { + ObjectNode encoded = parts.addObject(); + encoded.put("type", part.type().name()); + encoded.put("text", part.text()); + encoded.put("mimeType", part.mimeType()); + byte[] data = part.data(); + encoded.put("data", data == null ? null : Base64.getEncoder().encodeToString(data)); + encoded.set("metadata", encodeMetadata(part.metadata())); + } + ArrayNode calls = node.putArray("toolCalls"); + for (ToolCall call : message.toolCalls()) { + calls.add(encodeToolCall(call)); + } + node.set("metadata", encodeMetadata(message.metadata())); + return node; + } + + private static ChatMessage decodeMessage(JsonNode node) { + List content = new ArrayList<>(); + for (JsonNode part : node.path("content")) { + String data = text(part, "data"); + content.add(new ContentPart(ContentPart.Type.valueOf(part.path("type").asText("TEXT")), text(part, "text"), + text(part, "mimeType"), data == null ? null : Base64.getDecoder().decode(data), + decodeMetadata(part.path("metadata")))); + } + List calls = new ArrayList<>(); + for (JsonNode call : node.path("toolCalls")) { + calls.add(decodeToolCall(call)); + } + return new ChatMessage(ChatRole.valueOf(node.path("role").asText()), content, calls, text(node, "toolCallId"), + text(node, "toolName"), decodeMetadata(node.path("metadata"))); + } + + private static ObjectNode encodeToolCall(ToolCall call) { + ObjectNode node = MAPPER.createObjectNode(); + node.put("id", call.id()); + node.put("name", call.name()); + node.put("arguments", call.arguments()); + return node; + } + + private static ToolCall decodeToolCall(JsonNode node) { + return new ToolCall(node.path("id").asText(), node.path("name").asText(), node.path("arguments").asText("{}")); + } + + private static ObjectNode encodeUsage(Usage usage) { + ObjectNode node = MAPPER.createObjectNode(); + Usage value = usage == null ? Usage.unknown() : usage; + node.put("inputTokens", value.inputTokens()); + node.put("outputTokens", value.outputTokens()); + node.put("totalTokens", value.totalTokens()); + return node; + } + + private static Usage decodeUsage(JsonNode node) { + if (node == null || node.isMissingNode()) { + return Usage.unknown(); + } + return new Usage(number(node, "inputTokens"), number(node, "outputTokens"), number(node, "totalTokens")); + } + + private static ObjectNode encodeOptions(GenerationOptions options) { + ObjectNode node = MAPPER.createObjectNode(); + GenerationOptions value = options == null ? GenerationOptions.defaults() : options; + node.put("temperature", value.temperature()); + node.put("topP", value.topP()); + node.put("maxTokens", value.maxTokens()); + ArrayNode stops = node.putArray("stopSequences"); + value.stopSequences().forEach(stops::add); + ToolChoice choice = value.toolChoice(); + ObjectNode encodedChoice = node.putObject("toolChoice"); + encodedChoice.put("mode", choice.mode().name()); + encodedChoice.put("toolName", choice.toolName()); + ResponseFormat format = value.responseFormat(); + ObjectNode encodedFormat = node.putObject("responseFormat"); + encodedFormat.put("type", format.type().name()); + encodedFormat.put("name", format.name()); + encodedFormat.put("schema", format.schema()); + node.set("metadata", encodeMetadata(value.metadata())); + return node; + } + + private static ObjectNode encodeMetadata(Map metadata) { + ObjectNode node = MAPPER.createObjectNode(); + if (metadata == null) { + return node; + } + new TreeMap<>(metadata).forEach((key, value) -> { + if (value != null && !PROCESS_LOCAL_METADATA.contains(key)) { + node.set(key, MAPPER.valueToTree(value)); + } + }); + return node; + } + + private static Map decodeMetadata(JsonNode node) { + if (node == null || !node.isObject()) { + return Map.of(); + } + Map metadata = new LinkedHashMap<>(); + node.fields() + .forEachRemaining(entry -> metadata.put(entry.getKey(), MAPPER.convertValue(entry.getValue(), Object.class))); + return metadata; + } + + private static Long number(JsonNode node, String field) { + JsonNode value = node.path(field); + return value.isNull() || value.isMissingNode() ? null : value.asLong(); + } + + private static String text(JsonNode node, String field) { + JsonNode value = node.path(field); + return value.isNull() || value.isMissingNode() ? null : value.asText(); + } + + static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is required by the Java platform", ex); + } + } + + private static String write(JsonNode node) { + try { + return MAPPER.writeValueAsString(node); + } catch (JsonProcessingException ex) { + throw new ModelTurnJournalException("Cannot encode journal payload", ex); + } + } + + private static JsonNode read(String json) { + try { + return MAPPER.readTree(Objects.requireNonNull(json, "journal payload cannot be null")); + } catch (JsonProcessingException ex) { + throw new ModelTurnJournalException("Cannot decode journal payload", ex); + } + } } diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java index 37191fae6..a019d5a64 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -38,18 +38,15 @@ * *

* This component is engine-neutral: it knows the neutral chat contracts and the journal, - * not graphs, streams, checkpoints or schedulers. An engine supplies the stream identity of - * its invocation and the ordinal of the model call, then records streamed text as it - * arrives and commits the terminal response before dispatching any tool. + * not graphs, streams, checkpoints or schedulers. An engine supplies a {@link Turn} for the + * logical model call, then records streamed text as it arrives and commits the terminal + * response before dispatching any tool. * *

- * A turn is identified by the stream, the agent name and an engine-supplied ordinal. The - * recorder deliberately does not derive the ordinal from the conversation: summarisation - * and history trimming legitimately shrink the message list, so any count over it can move - * backwards and collide with an earlier turn. See {@link #begin(String, long)} for what the - * engine must guarantee. Two agents sharing one stream must not share a name; when they - * do, the request fingerprint rejects the mismatch instead of replaying another agent's - * response. + * The engine owns turn identities and their recovery: the same logical call must retain + * its identity across retries and restarts, and different calls within a stream must have + * different turn ids. Summarisation and history trimming do not change that identity. The + * recorder uses the supplied ids without allocating or advancing execution positions. * *

* The request fingerprint is taken from the request the provider actually receives, after @@ -63,11 +60,11 @@ *

* Entry ids of one turn: *

* @@ -80,23 +77,21 @@ public final class ModelTurnRecorder { private final RunJournal journal; - private final String agentName; - - private ModelTurnRecorder(RunJournal journal, String agentName) { + private ModelTurnRecorder(RunJournal journal) { this.journal = requireNonNull(journal, "journal cannot be null"); - this.agentName = requireNonNull(agentName, "agentName cannot be null"); } - /** Builds a recorder for one agent, or returns {@code null} when journaling is off. */ - public static ModelTurnRecorder of(RunJournal journal, String agentName) { - return journal == null ? null : new ModelTurnRecorder(journal, agentName); + /** Builds a recorder, or returns {@code null} when journaling is off. */ + public static ModelTurnRecorder of(RunJournal journal) { + return journal == null ? null : new ModelTurnRecorder(journal); } /** - * One logical model call. + * Engine-supplied identity of one logical model call. The engine restores the same + * identity on recovery and assigns distinct turn ids to different calls within a stream. * @param streamId the engine's durable stream identity, for example a graph thread id or * an agent run id - * @param turnId the agent and turn identity within that stream + * @param turnId the stable logical call identity within that stream */ public record Turn(String streamId, String turnId) { @@ -107,37 +102,6 @@ public record Turn(String streamId, String turnId) { } - /** - * Resolves the turn this model call belongs to. - * - *

- * The engine owns the ordinal and must guarantee three properties: - *

- * A counter kept in persisted engine state and advanced in the same update that - * publishes the model result satisfies all three. - * @param streamId the engine's stream identity; an engine without one cannot journal - * @param ordinal the engine's monotonic model-call ordinal within the stream - * @throws IllegalArgumentException when the stream identity is blank or the ordinal is - * negative - */ - public Turn begin(String streamId, long ordinal) { - if (streamId == null || streamId.isBlank()) { - throw new IllegalArgumentException("Journal persistence requires a stream identity"); - } - if (ordinal < 0) { - throw new IllegalArgumentException("Model-call ordinal must not be negative: " + ordinal); - } - return new Turn(streamId, agentName + ":" + ordinal); - } - /** * Returns the recorded response of this turn when the request is the one that produced * it. @@ -149,7 +113,7 @@ public Turn begin(String streamId, long ordinal) { * documentation. A recorded request without a recorded response was interrupted after a * provider call was issued and also fails closed, because the outcome is unknown rather * than missing. - * @param turn the turn resolved by {@link #begin(String, long)} + * @param turn the engine-supplied identity of this logical call * @param request the request about to be issued * @return the recorded response, or empty when the model has not been called yet * @throws ModelTurnJournalException when the recorded request differs, or when a turn @@ -163,7 +127,7 @@ public Optional replay(Turn turn, ChatRequest request) { throw new ModelTurnJournalException("Model turn " + turn.turnId() + " was recorded for a different request; refusing to replay it. This happens when the" + " request is rewritten non-deterministically, for example by summarisation, or when" - + " the engine reuses an ordinal", + + " the engine reuses a turn identity", new RunStoreConflictException( "Journal entry id identifies different data: " + turn.streamId() + "/" + turn.turnId())); } diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java index 422e8f6eb..5cb87f31a 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java @@ -26,8 +26,9 @@ *

* The contract deliberately knows no graph, session, scheduler or streaming library: it * depends on the neutral chat contracts and on {@code io.github.agentic.ai.runtime.execution.run}. - * Graph-backed and graph-independent engines share it, and each supplies its own stream - * identity. + * Graph-backed and graph-independent engines share it, and each supplies stable stream + * and turn identities. The engine restores the same identity for a recovered logical call + * and gives different calls distinct turn ids within a stream. * *

* The engine is responsible for one ordering rule: {@link io.github.agentic.ai.agent.durable.ModelTurnRecorder#commit} diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index 331012b95..f5519864f 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -51,8 +51,8 @@ class ModelTurnRecorderTest { @Test void recordsOnlyTheRequestFingerprintBeforeTheCall() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); recorder.recordRequest(turn, request(FIRST_TURN)); @@ -105,8 +105,8 @@ void equivalentToolArgumentsInHistoryDoNotChangeTheRequestFingerprint() { @Test void commitsTheResponseAndItsPlanInOneAppend() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); recorder.commit(turn, toolCallingResponse("provider-call-1"), List.of(TOOLS.get(0))); @@ -121,15 +121,19 @@ void commitsTheResponseAndItsPlanInOneAppend() { } @Test - void replaysTheRecordedResponseAndPlanWithoutCallingTheModel() { + void replaysTheRecordedResponseAfterRecorderIsRecreated() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "planner/visit-3/model-2"); recorder.recordRequest(turn, request(FIRST_TURN)); recorder.commit(turn, toolCallingResponse("provider-call-1"), TOOLS); - Optional replayed = recorder.replay(recorder.begin(STREAM, 0), request(FIRST_TURN)); + ModelTurnRecorder recovered = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn restored = new ModelTurnRecorder.Turn(STREAM, "planner/visit-3/model-2"); + Optional replayed = recovered.replay(restored, request(FIRST_TURN)); + assertThat(journal.entryIds(STREAM)).containsExactly("planner/visit-3/model-2#req", + "planner/visit-3/model-2#resp", "planner/visit-3/model-2#task.0"); assertThat(replayed).isPresent(); assertThat(replayed.orElseThrow().toolCalls()).extracting(ToolCall::name).containsExactly("search"); assertThat(replayed.orElseThrow().toolCalls()).extracting(ToolCall::arguments) @@ -139,11 +143,11 @@ void replaysTheRecordedResponseAndPlanWithoutCallingTheModel() { @Test void rejectsReplayWhenTheRecordedRequestDiffers() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - recorder.recordRequest(recorder.begin(STREAM, 0), request(FIRST_TURN)); - recorder.commit(recorder.begin(STREAM, 0), toolCallingResponse("provider-call-1"), TOOLS); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + recorder.recordRequest(new ModelTurnRecorder.Turn(STREAM, "agent:0"), request(FIRST_TURN)); + recorder.commit(new ModelTurnRecorder.Turn(STREAM, "agent:0"), toolCallingResponse("provider-call-1"), TOOLS); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); List different = List.of(ChatMessage.user("a different instruction")); assertThatThrownBy(() -> recorder.replay(turn, request(different))) @@ -155,10 +159,10 @@ void rejectsReplayWhenTheRecordedRequestDiffers() { @Test void anInterruptedTurnFailsClosedInsteadOfAskingAgain() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - recorder.recordRequest(recorder.begin(STREAM, 0), request(FIRST_TURN)); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + recorder.recordRequest(new ModelTurnRecorder.Turn(STREAM, "agent:0"), request(FIRST_TURN)); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN))) .isInstanceOf(ModelTurnJournalException.class) @@ -184,9 +188,10 @@ public void append(String streamId, List entries) { throw new IllegalStateException("journal unavailable"); } }; - ModelTurnRecorder recorder = ModelTurnRecorder.of(failing, "agent"); + ModelTurnRecorder recorder = ModelTurnRecorder.of(failing); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); - assertThatThrownBy(() -> recorder.recordRequest(recorder.begin(STREAM, 0), request(FIRST_TURN))) + assertThatThrownBy(() -> recorder.recordRequest(turn, request(FIRST_TURN))) .isInstanceOf(ModelTurnJournalException.class) .hasRootCauseMessage("journal unavailable"); } @@ -194,13 +199,13 @@ public void append(String streamId, List entries) { @Test void aCommittedTurnAddsNoSecondPlanWhenTheProviderRenamesTheCallId() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); recorder.recordRequest(turn, request(FIRST_TURN)); recorder.commit(turn, toolCallingResponse("provider-call-1"), TOOLS); // Recovery replays; it must not append a new task for the renamed call id. - recorder.replay(recorder.begin(STREAM, 0), request(FIRST_TURN)); + recorder.replay(new ModelTurnRecorder.Turn(STREAM, "agent:0"), request(FIRST_TURN)); assertThat(journal.entryIds(STREAM)).containsExactly("agent:0#req", "agent:0#resp", "agent:0#task.0"); } @@ -208,8 +213,8 @@ void aCommittedTurnAddsNoSecondPlanWhenTheProviderRenamesTheCallId() { @Test void streamedTextIsRecordedUnderOneStableAttempt() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 0); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); String attempt = ModelTurnRecorder.newAttempt(); recorder.recordDelta(turn, attempt, 0, "he"); @@ -224,12 +229,20 @@ void streamedTextIsRecordedUnderOneStableAttempt() { } @Test - void turnIdentityComesFromTheEngineOrdinalNotFromTheConversation() { - ModelTurnRecorder recorder = ModelTurnRecorder.of(new MemoryRunJournal(), "agent"); - - assertThat(recorder.begin(STREAM, 0).turnId()).isEqualTo("agent:0"); - assertThat(recorder.begin(STREAM, 7).turnId()).isEqualTo("agent:7"); - assertThat(recorder.begin(STREAM, 42).turnId()).isEqualTo("agent:42"); + void differentCallsWithTheSameRequestHaveIndependentRecords() { + MemoryRunJournal journal = new MemoryRunJournal(); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn first = new ModelTurnRecorder.Turn(STREAM, "first-call"); + ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(STREAM, "next-call"); + recorder.recordRequest(first, request(FIRST_TURN)); + recorder.commit(first, toolCallingResponse("call-1"), TOOLS); + + assertThat(recorder.replay(next, request(FIRST_TURN))).isEmpty(); + recorder.recordRequest(next, request(FIRST_TURN)); + recorder.commit(next, toolCallingResponse("call-2"), TOOLS); + + assertThat(journal.entryIds(STREAM)).containsExactly("first-call#req", "first-call#resp", "first-call#task.0", + "next-call#req", "next-call#resp", "next-call#task.0"); } /** @@ -240,24 +253,23 @@ void turnIdentityComesFromTheEngineOrdinalNotFromTheConversation() { @Test void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); for (long ordinal = 0; ordinal < 3; ordinal++) { - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, ordinal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:" + ordinal); recorder.recordRequest(turn, request(List.of(ChatMessage.user("question " + ordinal)))); recorder.commit(turn, toolCallingResponse("call-" + ordinal), TOOLS); } // Summarisation replaced the history with a summary plus a short tail, so the - // conversation now holds fewer assistant messages than before. The engine keeps - // counting, so the next turn is new instead of colliding with turns 0..2. + // conversation now holds fewer assistant messages than before. The engine supplies + // a new identity independently of that history. List summarisedHistory = List.of(ChatMessage.system("summary of earlier turns"), ChatMessage.user("question 3")); - ModelTurnRecorder.Turn next = recorder.begin(STREAM, 3); + ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(STREAM, "after-summary"); - assertThat(next.turnId()).isEqualTo("agent:3"); assertThat(recorder.replay(next, request(summarisedHistory))).isEmpty(); recorder.recordRequest(next, request(summarisedHistory)); - assertThat(journal.find(STREAM, "agent:3#req")).isPresent(); + assertThat(journal.find(STREAM, "after-summary#req")).isPresent(); } /** @@ -269,8 +281,8 @@ void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { @Test void aNonDeterministicRewriteOfTheSameTurnFailsClosed() { MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal, "agent"); - ModelTurnRecorder.Turn turn = recorder.begin(STREAM, 5); + ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "summary-turn"); recorder.recordRequest(turn, request(List.of(ChatMessage.system("summary, first attempt"), ChatMessage.user("question")))); recorder.commit(turn, toolCallingResponse("call-1"), TOOLS); @@ -279,25 +291,14 @@ void aNonDeterministicRewriteOfTheSameTurnFailsClosed() { List rebuilt = List.of(ChatMessage.system("summary, second attempt"), ChatMessage.user("question")); - assertThatThrownBy(() -> recorder.replay(recorder.begin(STREAM, 5), request(rebuilt))) + assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn(STREAM, "summary-turn"), request(rebuilt))) .isInstanceOf(ModelTurnJournalException.class) .hasMessageContaining("summarisation"); } - @Test - void aMissingStreamIdentityOrNegativeOrdinalIsRejected() { - ModelTurnRecorder recorder = ModelTurnRecorder.of(new MemoryRunJournal(), "agent"); - - assertThatThrownBy(() -> recorder.begin(" ", 0)).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("stream identity"); - assertThatThrownBy(() -> recorder.begin(null, 0)).isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> recorder.begin(STREAM, -1)).isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("must not be negative"); - } - @Test void aDisabledJournalYieldsNoRecorder() { - assertThat(ModelTurnRecorder.of(null, "agent")).isNull(); + assertThat(ModelTurnRecorder.of(null)).isNull(); } private static ChatRequest request(List messages) { From 5f09fb2d9d9b52066e0bf8e8eb08ed92d030268b Mon Sep 17 00:00:00 2001 From: GMJneko Date: Thu, 29 Oct 2026 17:01:52 +0800 Subject: [PATCH 03/12] fix(agent): restore original model turn codec --- .../ai/agent/durable/ModelTurnCodec.java | 587 ++++++++---------- 1 file changed, 262 insertions(+), 325 deletions(-) diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java index 0711f5976..668f7916d 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java @@ -18,358 +18,295 @@ import io.github.agentic.ai.api.chat.ChatMessage; import io.github.agentic.ai.api.chat.ChatRequest; import io.github.agentic.ai.api.chat.ChatResponse; -import io.github.agentic.ai.api.chat.ChatRole; import io.github.agentic.ai.api.chat.ContentPart; -import io.github.agentic.ai.api.chat.FinishReason; import io.github.agentic.ai.api.chat.GenerationOptions; -import io.github.agentic.ai.api.chat.ResponseFormat; import io.github.agentic.ai.api.chat.ToolCall; -import io.github.agentic.ai.api.chat.ToolChoice; import io.github.agentic.ai.api.chat.ToolDefinition; -import io.github.agentic.ai.api.chat.Usage; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.json.JsonMapper; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Base64; import java.util.HexFormat; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.TreeMap; +import java.util.TreeSet; /** - * Explicit, version-tagged JSON payloads for model-turn journal entries. + * Version-tagged JSON payloads for model-turn journal entries. + * + *

+ * The neutral chat contracts are bound by their declared record components, not by a + * hand-written field-by-field codec: the payload and the type stay described in one place, + * and a codec bug cannot desynchronise the write and read paths. What is written by hand + * is only where the recorded shape must deliberately differ from the type: + *

    + *
  • {@link RequestFingerprint} - a request is reduced to its fingerprint, so the body is + * never journaled and adapter-level {@code attributes} are excluded from the hash;
  • + *
  • {@link ToolPlan} - the provider's tool-call id is excluded, because it correlates + * protocol messages and is not a task identity;
  • + *
  • {@link Delta} - streamed text is a single scalar.
  • + *
* *

- * This is deliberately not a Java object serializer: a recorded response must stay - * readable after a class changes, so every field is written by name and unknown fields - * are ignored on read. The payload types below are the schema identifiers carried in - * {@code RunPayload#type()}; they are stable data, never Java class names. + * No Java type information is written: this mapper never enables default typing, so a + * payload stays readable by any process and after a class moves. Unknown fields are + * ignored on read so a newer writer can add fields without invalidating old records, and + * property and map-key ordering is pinned so equal data always produces equal JSON - which + * is what makes {@link #requestHash(ChatRequest)} stable. * *

- * Request bodies are never journaled. A request is reduced to a SHA-256 fingerprint of its - * canonical form, which is enough to prove a recovered turn is the same request without - * persisting prompts that may contain sensitive data. Responses and tool arguments are - * recorded, because replaying them is the purpose of the journal; applications apply - * their data-protection policy to the journal backend. + * Request bodies are never journaled. Responses and tool arguments are, because replaying + * them is the purpose of the journal; applications apply their data-protection policy to + * the journal backend. */ public final class ModelTurnCodec { - /** - * Version of every payload schema defined here. - */ - public static final int SCHEMA_VERSION = 1; - - /** - * {@code MODEL_CALL}: the request fingerprint, written before the provider call. - */ - public static final String REQUEST_TYPE = "argi.model-request"; - - /** - * {@code MODEL_CALL}: the complete terminal response. - */ - public static final String RESPONSE_TYPE = "argi.model-response"; - - /** - * {@code TASK_CALL}: one planned tool call, committed with its response. - */ - public static final String PLAN_TYPE = "argi.tool-plan"; - - /** - * {@code EVENT}: streamed text received before the terminal response. - */ - public static final String DELTA_TYPE = "argi.model-delta"; - - /** - * Metadata that belongs to one process run: protocol markers and handles into - * in-memory tool-binding snapshots. A recovering invocation re-derives these, so - * recording them would only replay stale state. - */ - private static final Set PROCESS_LOCAL_METADATA = Set.of( - "argiFinalResponse", - "argiTextDelta", - "argi.toolBindingSnapshot", - "argi.toolBindingSelection", - "argi.dynamicToolBindings", - "argiModelFailure" - ); - - private static final JsonMapper MAPPER = JsonMapper.builder().build(); - - private ModelTurnCodec() { - } - - /** - * Canonical fingerprint of the request a provider actually receives: ordered messages, - * disclosed tool definitions and effective generation options. Two requests with the - * same fingerprint are the same logical model call. - */ - public static String requestHash(ChatRequest request) { - return sha256(encodeRequestBody(Objects.requireNonNull(request, "request cannot be null"))); - } - - /** - * The {@link #REQUEST_TYPE} payload: the fingerprint only, never the request body. - */ - public static String encodeRequest(ChatRequest request) { - ObjectNode root = MAPPER.createObjectNode(); - root.put("schemaVersion", SCHEMA_VERSION); - root.put("requestHash", requestHash(request)); - return write(root); - } - - /** - * Reads the fingerprint recorded in a {@link #REQUEST_TYPE} payload. - */ - public static String decodeRequestHash(String json) { - return text(read(json), "requestHash"); - } - - /** - * The {@link #RESPONSE_TYPE} payload. - */ - public static String encodeResponse(ChatResponse response) { - ObjectNode root = MAPPER.createObjectNode(); - root.put("schemaVersion", SCHEMA_VERSION); - root.put("id", response.id()); - root.put("model", response.model()); - root.put("finishReason", response.finishReason() == null ? null : response.finishReason().name()); - root.set("usage", encodeUsage(response.usage())); - root.set("metadata", encodeMetadata(response.metadata())); - ArrayNode messages = root.putArray("messages"); - for (ChatMessage message : response.messages()) { - messages.add(encodeMessage(message)); - } - ArrayNode toolCalls = root.putArray("toolCalls"); - for (ToolCall call : response.toolCalls()) { - toolCalls.add(encodeToolCall(call)); - } - return write(root); - } - - public static ChatResponse decodeResponse(String json) { - JsonNode root = read(json); - List messages = new ArrayList<>(); - for (JsonNode message : root.path("messages")) { - messages.add(decodeMessage(message)); - } - List toolCalls = new ArrayList<>(); - for (JsonNode call : root.path("toolCalls")) { - toolCalls.add(decodeToolCall(call)); - } - String finishReason = text(root, "finishReason"); - return new ChatResponse(text(root, "id"), text(root, "model"), messages, toolCalls, - finishReason == null ? null : FinishReason.valueOf(finishReason), decodeUsage(root.path("usage")), - decodeMetadata(root.path("metadata"))); - } - - /** - * The {@link #DELTA_TYPE} payload. - */ - public static String encodeDelta(String text) { - ObjectNode root = MAPPER.createObjectNode(); - root.put("schemaVersion", SCHEMA_VERSION); - root.put("text", text); - return write(root); - } - - /** - * The {@link #PLAN_TYPE} payload. The tool-call id assigned by the provider is - * deliberately absent: it correlates protocol messages, it is not a task identity. - */ - public static String encodeToolPlan(ToolCall call, ToolDefinition definition, int ordinal) { - ObjectNode root = MAPPER.createObjectNode(); - root.put("schemaVersion", SCHEMA_VERSION); - root.put("ordinal", ordinal); - root.put("toolName", call.name()); - root.put("definitionVersion", definition == null ? null : definitionVersion(definition)); - root.put("argumentsHash", sha256(call.arguments())); - root.put("arguments", call.arguments()); - return write(root); - } - - /** - * Stable identity of one tool definition; a changed schema must not reuse a task id. - */ - public static String definitionVersion(ToolDefinition definition) { - ObjectNode root = MAPPER.createObjectNode(); - root.put("name", definition.name()); - root.put("description", definition.description()); - root.put("inputSchema", definition.inputSchema()); - return sha256(write(root)); - } - - private static String encodeRequestBody(ChatRequest request) { - ObjectNode root = MAPPER.createObjectNode(); - ArrayNode messages = root.putArray("messages"); - for (ChatMessage message : request.messages()) { - messages.add(encodeMessage(message)); - } - ArrayNode tools = root.putArray("tools"); - for (ToolDefinition definition : request.tools()) { - ObjectNode tool = tools.addObject(); - tool.put("name", definition.name()); - tool.put("description", definition.description()); - tool.put("inputSchema", definition.inputSchema()); - } - root.set("options", encodeOptions(request.options())); - return write(root); - } - - private static ObjectNode encodeMessage(ChatMessage message) { - ObjectNode node = MAPPER.createObjectNode(); - node.put("role", message.role().name()); - node.put("toolCallId", message.toolCallId()); - node.put("toolName", message.toolName()); - ArrayNode parts = node.putArray("content"); - for (ContentPart part : message.content()) { - ObjectNode encoded = parts.addObject(); - encoded.put("type", part.type().name()); - encoded.put("text", part.text()); - encoded.put("mimeType", part.mimeType()); - byte[] data = part.data(); - encoded.put("data", data == null ? null : Base64.getEncoder().encodeToString(data)); - encoded.set("metadata", encodeMetadata(part.metadata())); - } - ArrayNode calls = node.putArray("toolCalls"); - for (ToolCall call : message.toolCalls()) { - calls.add(encodeToolCall(call)); - } - node.set("metadata", encodeMetadata(message.metadata())); - return node; - } - - private static ChatMessage decodeMessage(JsonNode node) { - List content = new ArrayList<>(); - for (JsonNode part : node.path("content")) { - String data = text(part, "data"); - content.add(new ContentPart(ContentPart.Type.valueOf(part.path("type").asText("TEXT")), text(part, "text"), - text(part, "mimeType"), data == null ? null : Base64.getDecoder().decode(data), - decodeMetadata(part.path("metadata")))); - } - List calls = new ArrayList<>(); - for (JsonNode call : node.path("toolCalls")) { - calls.add(decodeToolCall(call)); - } - return new ChatMessage(ChatRole.valueOf(node.path("role").asText()), content, calls, text(node, "toolCallId"), - text(node, "toolName"), decodeMetadata(node.path("metadata"))); - } - - private static ObjectNode encodeToolCall(ToolCall call) { - ObjectNode node = MAPPER.createObjectNode(); - node.put("id", call.id()); - node.put("name", call.name()); - node.put("arguments", call.arguments()); - return node; - } - - private static ToolCall decodeToolCall(JsonNode node) { - return new ToolCall(node.path("id").asText(), node.path("name").asText(), node.path("arguments").asText("{}")); - } - - private static ObjectNode encodeUsage(Usage usage) { - ObjectNode node = MAPPER.createObjectNode(); - Usage value = usage == null ? Usage.unknown() : usage; - node.put("inputTokens", value.inputTokens()); - node.put("outputTokens", value.outputTokens()); - node.put("totalTokens", value.totalTokens()); - return node; - } - - private static Usage decodeUsage(JsonNode node) { - if (node == null || node.isMissingNode()) { - return Usage.unknown(); - } - return new Usage(number(node, "inputTokens"), number(node, "outputTokens"), number(node, "totalTokens")); - } - - private static ObjectNode encodeOptions(GenerationOptions options) { - ObjectNode node = MAPPER.createObjectNode(); - GenerationOptions value = options == null ? GenerationOptions.defaults() : options; - node.put("temperature", value.temperature()); - node.put("topP", value.topP()); - node.put("maxTokens", value.maxTokens()); - ArrayNode stops = node.putArray("stopSequences"); - value.stopSequences().forEach(stops::add); - ToolChoice choice = value.toolChoice(); - ObjectNode encodedChoice = node.putObject("toolChoice"); - encodedChoice.put("mode", choice.mode().name()); - encodedChoice.put("toolName", choice.toolName()); - ResponseFormat format = value.responseFormat(); - ObjectNode encodedFormat = node.putObject("responseFormat"); - encodedFormat.put("type", format.type().name()); - encodedFormat.put("name", format.name()); - encodedFormat.put("schema", format.schema()); - node.set("metadata", encodeMetadata(value.metadata())); - return node; - } - - private static ObjectNode encodeMetadata(Map metadata) { - ObjectNode node = MAPPER.createObjectNode(); - if (metadata == null) { - return node; - } - new TreeMap<>(metadata).forEach((key, value) -> { - if (value != null && !PROCESS_LOCAL_METADATA.contains(key)) { - node.set(key, MAPPER.valueToTree(value)); - } - }); - return node; - } - - private static Map decodeMetadata(JsonNode node) { - if (node == null || !node.isObject()) { - return Map.of(); - } - Map metadata = new LinkedHashMap<>(); - node.fields() - .forEachRemaining(entry -> metadata.put(entry.getKey(), MAPPER.convertValue(entry.getValue(), Object.class))); - return metadata; - } - - private static Long number(JsonNode node, String field) { - JsonNode value = node.path(field); - return value.isNull() || value.isMissingNode() ? null : value.asLong(); - } - - private static String text(JsonNode node, String field) { - JsonNode value = node.path(field); - return value.isNull() || value.isMissingNode() ? null : value.asText(); - } - - static String sha256(String value) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException ex) { - throw new IllegalStateException("SHA-256 is required by the Java platform", ex); - } - } - - private static String write(JsonNode node) { - try { - return MAPPER.writeValueAsString(node); - } catch (JsonProcessingException ex) { - throw new ModelTurnJournalException("Cannot encode journal payload", ex); - } - } - - private static JsonNode read(String json) { - try { - return MAPPER.readTree(Objects.requireNonNull(json, "journal payload cannot be null")); - } catch (JsonProcessingException ex) { - throw new ModelTurnJournalException("Cannot decode journal payload", ex); - } - } + /** Version of every payload schema defined here, carried by {@code RunPayload}. */ + public static final int SCHEMA_VERSION = 1; + + /** {@code MODEL_CALL}: the request fingerprint, written before the provider call. */ + public static final String REQUEST_TYPE = "argi.model-request"; + + /** {@code MODEL_CALL}: the complete terminal response. */ + public static final String RESPONSE_TYPE = "argi.model-response"; + + /** {@code TASK_CALL}: one planned tool call, committed with its response. */ + public static final String PLAN_TYPE = "argi.tool-plan"; + + /** {@code EVENT}: streamed text received before the terminal response. */ + public static final String DELTA_TYPE = "argi.model-delta"; + + /** + * Metadata that belongs to one process run: protocol markers and handles into + * in-memory tool-binding snapshots. A recovering invocation re-derives these, so + * recording them would only replay stale state - and a snapshot id that changes per + * process would change the request fingerprint across a restart. + */ + private static final Set PROCESS_LOCAL_METADATA = Set.of("argiFinalResponse", "argiTextDelta", + "argi.toolBindingSnapshot", "argi.toolBindingSelection", "argi.dynamicToolBindings", "argiModelFailure"); + + private static final JsonMapper MAPPER = JsonMapper.builder() + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .build(); + + private ModelTurnCodec() { + } + + /** + * Canonical fingerprint of the request a provider actually receives: ordered messages, + * disclosed tool definitions and effective generation options. + * + *

+ * {@link ChatRequest#attributes()} is deliberately excluded. It is a transport escape + * hatch whose meaning differs per adapter (Spring AI passes it to advisor parameters, + * LangChain4j to listener attributes, Google ADK only checks emptiness), and in the + * graph-independent engine it carries the whole mutable agent state. Hashing it would + * bind request identity to adapter internals and to state that changes for reasons + * unrelated to the request. An application whose adapters derive generation semantics + * from attributes must not rely on replay across differing attribute values. + */ + public static String requestHash(ChatRequest request) { + Objects.requireNonNull(request, "request cannot be null"); + RequestFingerprint fingerprint = new RequestFingerprint(sanitize(request.messages()), request.tools(), + request.options()); + return sha256(write(fingerprint)); + } + + /** The {@link #REQUEST_TYPE} payload: the fingerprint only, never the request body. */ + public static String encodeRequest(ChatRequest request) { + return write(new RequestRecord(requestHash(request))); + } + + /** Reads the fingerprint recorded in a {@link #REQUEST_TYPE} payload. */ + public static String decodeRequestHash(String json) { + return read(json, RequestRecord.class).requestHash(); + } + + /** The {@link #RESPONSE_TYPE} payload. */ + public static String encodeResponse(ChatResponse response) { + return write(sanitize(Objects.requireNonNull(response, "response cannot be null"))); + } + + public static ChatResponse decodeResponse(String json) { + return read(json, ChatResponse.class); + } + + /** The {@link #DELTA_TYPE} payload. */ + public static String encodeDelta(String text) { + return write(new Delta(text)); + } + + /** + * The {@link #PLAN_TYPE} payload. The tool-call id assigned by the provider is + * deliberately absent: it correlates protocol messages, it is not a task identity. + * + *

+ * Arguments are stored and hashed in canonical form, so two semantically equal + * invocations that differ only in whitespace or object-key order produce the same + * record and the same hash. The hash is intended to become an idempotency key for + * external effects, which only holds if formatting cannot change it. + */ + public static String encodeToolPlan(ToolCall call, ToolDefinition definition, int ordinal) { + String arguments = canonicalArguments(call.arguments()); + return write(new ToolPlan(ordinal, call.name(), definition == null ? null : definitionVersion(definition), + sha256(arguments), arguments)); + } + + /** + * Canonical form of a tool-argument JSON string: object keys sorted recursively, + * compact output. + * + *

+ * Keys are reordered explicitly rather than through a mapper feature: the mapper's + * ordering settings apply to POJO properties and {@code Map} entries, not to an + * {@code ObjectNode}, which preserves insertion order. Only objects are reordered. + * Array order carries meaning (argument lists are positional) and scalars have no order. + * Malformed JSON is returned unchanged rather than rejected: argument validation is + * opt-in, so a provider may legitimately produce arguments this codec cannot parse, and + * that must not fail a turn that would otherwise dispatch. + */ + public static String canonicalArguments(String arguments) { + if (arguments == null || arguments.isBlank()) { + return arguments; + } + try { + JsonNode tree = MAPPER.readTree(arguments); + return tree == null || !tree.isContainerNode() ? arguments : write(sortKeys(tree)); + } + catch (JsonProcessingException malformed) { + return arguments; + } + } + + private static JsonNode sortKeys(JsonNode node) { + if (node.isObject()) { + ObjectNode sorted = MAPPER.createObjectNode(); + TreeSet keys = new TreeSet<>(); + node.fieldNames().forEachRemaining(keys::add); + keys.forEach(key -> sorted.set(key, sortKeys(node.get(key)))); + return sorted; + } + if (node.isArray()) { + ArrayNode values = MAPPER.createArrayNode(); + node.forEach(child -> values.add(sortKeys(child))); + return values; + } + return node; + } + + /** Stable identity of one tool definition; a changed schema must not reuse a task id. */ + public static String definitionVersion(ToolDefinition definition) { + return sha256(write(new DefinitionRecord(definition.name(), definition.description(), + definition.inputSchema()))); + } + + private static List sanitize(List messages) { + return messages == null ? List.of() : messages.stream().map(ModelTurnCodec::sanitize).toList(); + } + + private static ChatResponse sanitize(ChatResponse response) { + return new ChatResponse(response.id(), response.model(), sanitize(response.messages()), response.toolCalls(), + response.finishReason(), response.usage(), sanitize(response.metadata())); + } + + private static ChatMessage sanitize(ChatMessage message) { + List content = message.content() + .stream() + .map(part -> new ContentPart(part.type(), part.text(), part.mimeType(), part.data(), + sanitize(part.metadata()))) + .toList(); + // Tool arguments are canonicalised here too: an assistant message replaying earlier + // calls must fingerprint identically no matter how the provider formatted them. + List toolCalls = message.toolCalls() + .stream() + .map(call -> new ToolCall(call.id(), call.name(), canonicalArguments(call.arguments()))) + .toList(); + return new ChatMessage(message.role(), content, toolCalls, message.toolCallId(), message.toolName(), + sanitize(message.metadata())); + } + + private static Map sanitize(Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return Map.of(); + } + Map retained = new LinkedHashMap<>(); + metadata.forEach((key, value) -> { + if (key != null && value != null && !PROCESS_LOCAL_METADATA.contains(key)) { + retained.put(key, value); + } + }); + return retained; + } + + private static T read(String json, Class type) { + try { + return MAPPER.readValue(Objects.requireNonNull(json, "journal payload cannot be null"), type); + } + catch (JsonProcessingException ex) { + throw new ModelTurnJournalException("Cannot decode journal payload as " + type.getSimpleName(), ex); + } + } + + private static String write(Object payload) { + try { + return MAPPER.writeValueAsString(payload); + } + catch (JsonProcessingException ex) { + throw new ModelTurnJournalException("Cannot encode journal payload " + payload.getClass().getSimpleName(), + ex); + } + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } + catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 is required by the Java platform", ex); + } + } + + /** Stored shape of {@link #REQUEST_TYPE}: the hash only, never the request body. */ + private record RequestRecord(String requestHash) { + } + + /** + * Hashed shape of a request. Excludes {@link ChatRequest#attributes()} on purpose; see + * {@link #requestHash(ChatRequest)}. + */ + private record RequestFingerprint(List messages, List tools, + GenerationOptions options) { + } + + /** Stored shape of {@link #PLAN_TYPE}; excludes the provider's tool-call id. */ + private record ToolPlan(int ordinal, String toolName, String definitionVersion, String argumentsHash, + String arguments) { + } + + /** Hashed shape of a tool definition; a changed schema must not reuse a task id. */ + private record DefinitionRecord(String name, String description, String inputSchema) { + } + + /** Stored shape of {@link #DELTA_TYPE}. */ + private record Delta(String text) { + } } From e12db61b817e9a3450f0682facf9cc02c6a77404 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Thu, 29 Oct 2026 17:08:36 +0800 Subject: [PATCH 04/12] fix(agent): correct journal test comment spelling --- .../github/agentic/ai/agent/durable/ModelTurnRecorderTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index f5519864f..94efeeb42 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -84,7 +84,7 @@ void arrayOrderAndMalformedArgumentsArePreserved() { // Array order is positional, so it carries meaning and must not be sorted. assertThat(ModelTurnCodec.canonicalArguments("{\"tags\":[\"b\",\"a\"]}")) .isEqualTo("{\"tags\":[\"b\",\"a\"]}"); - // Argument validation is opt-in, so an unparseable argument must pass through. + // Argument validation is opt-in, so an unparsable argument must pass through. assertThat(ModelTurnCodec.canonicalArguments("not json at all")).isEqualTo("not json at all"); assertThat(ModelTurnCodec.canonicalArguments("{}")).isEqualTo("{}"); assertThat(ModelTurnCodec.canonicalArguments(null)).isNull(); From 9e9539ed13c408444ecffb0102c8511d74f4bc2f Mon Sep 17 00:00:00 2001 From: GMJneko Date: Thu, 29 Oct 2026 18:11:50 +0800 Subject: [PATCH 05/12] refactor(runtime): route journal writes through RunStore --- .../durable/ModelTurnJournalException.java | 9 +- .../ai/agent/durable/ModelTurnRecorder.java | 52 +-- .../ai/agent/durable/package-info.java | 15 +- .../agent/durable/ModelTurnRecorderTest.java | 313 +++++++++++++----- .../execution/run/MemoryRunJournal.java | 95 ------ .../ai/runtime/execution/run/RunJournal.java | 66 ---- .../execution/run/RunJournalWriter.java | 55 +++ .../execution/run/RunStoreJournalWriter.java | 87 +++++ .../execution/run/MemoryRunJournalTest.java | 53 --- .../execution/run/RunJournalContractTest.java | 100 ------ .../run/RunStoreJournalWriterTest.java | 208 ++++++++++++ 11 files changed, 629 insertions(+), 424 deletions(-) delete mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java delete mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java create mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java create mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java delete mode 100644 argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java delete mode 100644 argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java create mode 100644 argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java index ad67e7143..36fb4af18 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java @@ -16,13 +16,14 @@ package io.github.agentic.ai.agent.durable; /** - * A model turn could not be journaled, or its recorded data does not describe the request - * being issued. + * A model-turn payload could not be encoded or decoded, a request/progress append failed, + * or recorded data does not describe the request being issued. Runtime commits through + * RunJournalWriter retain the original RunStore exception types. * *

* This is not a model failure. An engine that converts caught model exceptions into - * user-visible error messages must rethrow this type instead: continuing would let tool - * dispatch proceed on a turn whose plan was never recorded. + * user-visible error messages must rethrow this type instead. The runtime must also + * propagate writer commit failures before dispatching tools. */ public class ModelTurnJournalException extends IllegalStateException { diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java index a019d5a64..05b2f9920 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -20,7 +20,7 @@ import io.github.agentic.ai.api.chat.ToolCall; import io.github.agentic.ai.api.chat.ToolDefinition; import io.github.agentic.ai.runtime.execution.run.RunEntry; -import io.github.agentic.ai.runtime.execution.run.RunJournal; +import io.github.agentic.ai.runtime.execution.run.RunJournalWriter; import io.github.agentic.ai.runtime.execution.run.RunPayload; import io.github.agentic.ai.runtime.execution.run.RunStoreConflictException; @@ -39,14 +39,15 @@ *

* This component is engine-neutral: it knows the neutral chat contracts and the journal, * not graphs, streams, checkpoints or schedulers. An engine supplies a {@link Turn} for the - * logical model call, then records streamed text as it arrives and commits the terminal - * response before dispatching any tool. + * logical model call, then records streamed text as it arrives and prepares the terminal + * response and tool plan. The runtime commits those records with the corresponding + * checkpoint through {@link RunJournalWriter#commit} before dispatching any tool. * *

* The engine owns turn identities and their recovery: the same logical call must retain - * its identity across retries and restarts, and different calls within a stream must have - * different turn ids. Summarisation and history trimming do not change that identity. The - * recorder uses the supplied ids without allocating or advancing execution positions. + * its identity across retries and restarts, and different calls within a run must have + * different turn ids. Summarisation and history trimming do not change that identity. + * The recorder uses the supplied ids without allocating or advancing execution positions. * *

* The request fingerprint is taken from the request the provider actually receives, after @@ -65,33 +66,33 @@ * only *

  • {@code #resp} - the complete terminal response
  • *
  • {@code #task.} - the tool plan, committed atomically with the - * response
  • + * response and checkpoint by the runtime * * *

    * An interrupted turn is never re-asked: a recorded request without a recorded response is - * evidence that a call was already issued, and repeating it would make cost and outcome - * depend on how often a worker restarted. Budgeted re-asking belongs to the retry path. + * evidence that its outcome is unknown, whether or not the provider call was issued. + * Repeating it would make cost and outcome depend on how often a worker restarted. + * Budgeted re-asking belongs to the retry path. */ public final class ModelTurnRecorder { - private final RunJournal journal; + private final RunJournalWriter journal; - private ModelTurnRecorder(RunJournal journal) { + private ModelTurnRecorder(RunJournalWriter journal) { this.journal = requireNonNull(journal, "journal cannot be null"); } /** Builds a recorder, or returns {@code null} when journaling is off. */ - public static ModelTurnRecorder of(RunJournal journal) { + public static ModelTurnRecorder of(RunJournalWriter journal) { return journal == null ? null : new ModelTurnRecorder(journal); } /** * Engine-supplied identity of one logical model call. The engine restores the same - * identity on recovery and assigns distinct turn ids to different calls within a stream. - * @param streamId the engine's durable stream identity, for example a graph thread id or - * an agent run id - * @param turnId the stable logical call identity within that stream + * identity on recovery and assigns distinct turn ids to different calls within a run. + * @param streamId the run id bound to the runtime's journal writer + * @param turnId the stable logical call identity within that run */ public record Turn(String streamId, String turnId) { @@ -110,9 +111,8 @@ public record Turn(String streamId, String turnId) { * A recorded request whose fingerprint differs fails closed: replaying it would hand the * engine a plan the model never produced for this input. This is also how a * non-deterministic request rewrite surfaces after a restart; see the class - * documentation. A recorded request without a recorded response was interrupted after a - * provider call was issued and also fails closed, because the outcome is unknown rather - * than missing. + * documentation. A recorded request without a recorded response also fails closed, + * because the outcome is unknown rather than missing. * @param turn the engine-supplied identity of this logical call * @param request the request about to be issued * @return the recorded response, or empty when the model has not been called yet @@ -151,15 +151,17 @@ RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTur } /** - * Records the terminal response and, in the same atomic append, the tool plan derived - * from it. A failure here leaves no dispatchable plan behind, so the caller must treat - * it as a failure of the turn and must not dispatch the plan's tools. - * @param turn the turn being committed + * Prepares the terminal response and every tool plan without publishing any records. + * The runtime must commit this entire batch with the corresponding checkpoint through + * {@link RunJournalWriter#commit} before dispatching the first tool. Preparing the batch + * does not authorize dispatch. + * @param turn the turn being prepared * @param response the terminal response the engine is about to use * @param tools the tool definitions that were disclosed to the model, used to pin each * planned task to the schema the model saw + * @return an immutable batch containing the response followed by its tool plans */ - public void commit(Turn turn, ChatResponse response, Iterable tools) { + public List prepareResponse(Turn turn, ChatResponse response, Iterable tools) { List entries = new ArrayList<>(); entries.add(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.RESPONSE_TYPE, @@ -176,7 +178,7 @@ public void commit(Turn turn, ChatResponse response, Iterable to ModelTurnCodec.encodeToolPlan(call, byName.get(call.name()), ordinal)))); ordinal++; } - append(turn, entries); + return List.copyOf(entries); } /** diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java index 5cb87f31a..dc7754a49 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java @@ -26,13 +26,18 @@ *

    * The contract deliberately knows no graph, session, scheduler or streaming library: it * depends on the neutral chat contracts and on {@code io.github.agentic.ai.runtime.execution.run}. - * Graph-backed and graph-independent engines share it, and each supplies stable stream + * Graph-backed and graph-independent engines share it, and each supplies stable run * and turn identities. The engine restores the same identity for a recovered logical call - * and gives different calls distinct turn ids within a stream. + * and gives different calls distinct turn ids within a run. * *

    - * The engine is responsible for one ordering rule: {@link io.github.agentic.ai.agent.durable.ModelTurnRecorder#commit} - * must succeed before the first tool of that turn is dispatched. A failed commit means the - * plan was never recorded, so no tool may run. + * {@link io.github.agentic.ai.agent.durable.ModelTurnRecorder#prepareResponse} only prepares + * records. The runtime must commit the full batch together with the corresponding + * checkpoint through {@link io.github.agentic.ai.runtime.execution.run.RunJournalWriter#commit} + * before dispatching the first tool. Request fingerprints and diagnostic deltas use the + * same writer's append path, which retains the checkpoint. Both paths delegate to + * {@link io.github.agentic.ai.runtime.execution.run.RunStore#commit}; no independent + * journal backend is required. Planned TASK_CALL entries are journal data, not task + * lifecycle updates in {@code RunCommit.tasks}. */ package io.github.agentic.ai.agent.durable; diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index 94efeeb42..ff824cd4e 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -22,17 +22,35 @@ import io.github.agentic.ai.api.chat.FinishReason; import io.github.agentic.ai.api.chat.ToolCall; import io.github.agentic.ai.api.chat.ToolDefinition; -import io.github.agentic.ai.runtime.execution.run.MemoryRunJournal; +import io.github.agentic.ai.runtime.execution.PrincipalReference; +import io.github.agentic.ai.runtime.execution.TenantReference; +import io.github.agentic.ai.runtime.execution.TrustedExecutionContext; +import io.github.agentic.ai.runtime.execution.definition.DefinitionKind; +import io.github.agentic.ai.runtime.execution.definition.DefinitionReference; +import io.github.agentic.ai.runtime.execution.run.MemoryRunStore; +import io.github.agentic.ai.runtime.execution.run.RunCommit; import io.github.agentic.ai.runtime.execution.run.RunEntry; -import io.github.agentic.ai.runtime.execution.run.RunJournal; +import io.github.agentic.ai.runtime.execution.run.RunJournalWriter; +import io.github.agentic.ai.runtime.execution.run.RunLease; +import io.github.agentic.ai.runtime.execution.run.RunPayload; +import io.github.agentic.ai.runtime.execution.run.RunSnapshot; +import io.github.agentic.ai.runtime.execution.run.RunSpec; +import io.github.agentic.ai.runtime.execution.run.RunState; import io.github.agentic.ai.runtime.execution.run.RunStoreConflictException; +import io.github.agentic.ai.runtime.execution.run.RunStoreJournalWriter; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.function.Supplier; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -41,22 +59,50 @@ @Timeout(10) class ModelTurnRecorderTest { - private static final String STREAM = "stream-1"; + private static final String RUN_ID = "run-1"; private static final List FIRST_TURN = List.of(ChatMessage.user("search for argi")); private static final List TOOLS = List.of( new ToolDefinition("search", "search the web", "{\"type\":\"object\"}")); + private final MemoryRunStore store = new MemoryRunStore(); + + private RunSpec run; + + private RunLease lease; + + private RunStoreJournalWriter journal; + + private ModelTurnRecorder recorder; + + @BeforeEach + void setUp() { + TrustedExecutionContext context = TrustedExecutionContext.builder() + .tenant(new TenantReference("iam", "tenant")) + .principal(new PrincipalReference("iam", "user")) + .runId(RUN_ID) + .correlationId("trace") + .build(); + run = RunSpec.from(context, "app", "session", "submission", + DefinitionReference.of(DefinitionKind.AGENT, "agent", "1"), new RunPayload(1, "test.input", "{}")); + store.submit(run, context); + lease = store.acquire(run.namespace(), RUN_ID, UUID.randomUUID(), Duration.ofMinutes(1)); + RunSnapshot running = store.commit(run.namespace(), RUN_ID, lease, + new RunCommit(0, RunState.RUNNING, + new RunPayload(1, "test.checkpoint", "{\"phase\":\"MODEL_READY\"}"), List.of(), List.of(), + List.of())); + journal = new RunStoreJournalWriter(store, lease, running); + recorder = ModelTurnRecorder.of(journal); + } + @Test void recordsOnlyTheRequestFingerprintBeforeTheCall() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); recorder.recordRequest(turn, request(FIRST_TURN)); - RunEntry recorded = journal.find(STREAM, "agent:0#req").orElseThrow(); + RunEntry recorded = journal.find(RUN_ID, "agent:0#req").orElseThrow(); assertThat(recorded.kind()).isEqualTo(RunEntry.Kind.MODEL_CALL); assertThat(recorded.payload().type()).isEqualTo(ModelTurnCodec.REQUEST_TYPE); // The request body is never persisted: only the fingerprint is. @@ -103,16 +149,30 @@ void equivalentToolArgumentsInHistoryDoNotChangeTheRequestFingerprint() { } @Test - void commitsTheResponseAndItsPlanInOneAppend() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); + void preparingTheResponseReturnsAnImmutableBatchWithoutWriting() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunSnapshot before = journal.snapshot(); - recorder.commit(turn, toolCallingResponse("provider-call-1"), - List.of(TOOLS.get(0))); + List entries = recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS); - assertThat(journal.entryIds(STREAM)).containsExactly("agent:0#resp", "agent:0#task.0"); - RunEntry plan = journal.find(STREAM, "agent:0#task.0").orElseThrow(); + assertThat(entries).extracting(RunEntry::id).containsExactly("agent:0#resp", "agent:0#task.0"); + assertThatThrownBy(entries::clear).isInstanceOf(UnsupportedOperationException.class); + assertThat(journal.snapshot()).isSameAs(before); + assertThat(store.find(run.namespace(), RUN_ID)).contains(before); + } + + @Test + void commitsTheResponseAndItsPlanWithCheckpoint() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + long revision = journal.snapshot().revision(); + + commitResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); + + assertThat(journal.snapshot().revision()).isEqualTo(revision + 1); + assertThat(journal.snapshot().checkpoint()).isEqualTo(checkpoint(turn)); + assertThat(journal.snapshot().tasks()).isEmpty(); + assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#resp", "agent:0#task.0"); + RunEntry plan = journal.find(RUN_ID, "agent:0#task.0").orElseThrow(); assertThat(plan.kind()).isEqualTo(RunEntry.Kind.TASK_CALL); assertThat(plan.payload().type()).isEqualTo(ModelTurnCodec.PLAN_TYPE); assertThat(plan.payload().json()).contains("\"toolName\":\"search\"", "\"ordinal\":0", "\"argumentsHash\""); @@ -121,33 +181,100 @@ void commitsTheResponseAndItsPlanInOneAppend() { } @Test - void replaysTheRecordedResponseAfterRecorderIsRecreated() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "planner/visit-3/model-2"); - recorder.recordRequest(turn, request(FIRST_TURN)); - recorder.commit(turn, toolCallingResponse("provider-call-1"), TOOLS); + void referenceExecutionDispatchesOnlyAfterTheWholePlanAndCheckpointAreCommitted() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + long revision = journal.snapshot().revision(); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); + List calls = List.of(new ToolCall("provider-1", "search", "{\"q\":\"first\"}"), + new ToolCall("provider-2", "search", "{\"q\":\"second\"}")); + ChatResponse response = new ChatResponse("response", "model", List.of(ChatMessage.assistant("", calls)), + calls, FinishReason.TOOL_CALLS, null, Map.of()); + + executeReference(journal, turn, request(FIRST_TURN), () -> { + modelCalls.incrementAndGet(); + return response; + }, call -> { + RunSnapshot committed = store.find(run.namespace(), RUN_ID).orElseThrow(); + assertThat(committed.checkpoint()).isEqualTo(checkpoint(turn)); + assertThat(committed.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0", + "agent:0#task.1"); + assertThat(committed.tasks()).isEmpty(); + toolCalls.incrementAndGet(); + }); + + assertThat(modelCalls.get()).isEqualTo(1); + assertThat(toolCalls.get()).isEqualTo(2); + assertThat(journal.snapshot().revision()).isEqualTo(revision + 2); + } - ModelTurnRecorder recovered = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn restored = new ModelTurnRecorder.Turn(STREAM, "planner/visit-3/model-2"); - Optional replayed = recovered.replay(restored, request(FIRST_TURN)); + @Test + void referenceExecutionDoesNotDispatchAnyToolWhenCommitFails() { + IllegalStateException unavailable = new IllegalStateException("storage unavailable"); + RunJournalWriter failing = new RunJournalWriter() { + @Override + public Optional find(String runId, String entryId) { + return journal.find(runId, entryId); + } + + @Override + public void append(String runId, List entries) { + journal.append(runId, entries); + } - assertThat(journal.entryIds(STREAM)).containsExactly("planner/visit-3/model-2#req", + @Override + public void commit(String runId, RunPayload checkpoint, List entries) { + throw unavailable; + } + }; + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunPayload before = journal.snapshot().checkpoint(); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); + + assertThatThrownBy(() -> executeReference(failing, turn, request(FIRST_TURN), () -> { + modelCalls.incrementAndGet(); + return toolCallingResponse("call-1"); + }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); + + assertThat(modelCalls.get()).isEqualTo(1); + assertThat(toolCalls.get()).isZero(); + assertThat(journal.snapshot().checkpoint()).isEqualTo(before); + assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#req"); + assertThat(store.find(run.namespace(), RUN_ID)).contains(journal.snapshot()); + } + + @Test + void replaysCommittedPlanAfterWriterAndRecorderAreRecreatedWithoutCallingTheModel() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "planner/visit-3/model-2"); + recorder.recordRequest(turn, request(FIRST_TURN)); + commitResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); + + // Recreate the execution objects after plan persistence, before tool dispatch. + journal = new RunStoreJournalWriter(store, lease, store.find(run.namespace(), RUN_ID).orElseThrow()); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); + ChatResponse replayed = executeReference(journal, + new ModelTurnRecorder.Turn(RUN_ID, "planner/visit-3/model-2"), request(FIRST_TURN), () -> { + modelCalls.incrementAndGet(); + return toolCallingResponse("provider-call-2"); + }, call -> toolCalls.incrementAndGet()); + + assertThat(modelCalls.get()).isZero(); + assertThat(toolCalls.get()).isEqualTo(1); + assertThat(journal.snapshot().checkpoint()).isEqualTo(checkpoint(turn)); + assertThat(journal.snapshot().journal()).containsOnlyKeys("planner/visit-3/model-2#req", "planner/visit-3/model-2#resp", "planner/visit-3/model-2#task.0"); - assertThat(replayed).isPresent(); - assertThat(replayed.orElseThrow().toolCalls()).extracting(ToolCall::name).containsExactly("search"); - assertThat(replayed.orElseThrow().toolCalls()).extracting(ToolCall::arguments) - .containsExactly("{\"q\":\"argi\"}"); + assertThat(replayed.toolCalls()) + .containsExactly(new ToolCall("provider-call-1", "search", "{\"q\":\"argi\"}")); } @Test void rejectsReplayWhenTheRecordedRequestDiffers() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - recorder.recordRequest(new ModelTurnRecorder.Turn(STREAM, "agent:0"), request(FIRST_TURN)); - recorder.commit(new ModelTurnRecorder.Turn(STREAM, "agent:0"), toolCallingResponse("provider-call-1"), TOOLS); + recorder.recordRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); + commitResponse(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), toolCallingResponse("provider-call-1"), TOOLS); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); List different = List.of(ChatMessage.user("a different instruction")); assertThatThrownBy(() -> recorder.replay(turn, request(different))) @@ -158,29 +285,27 @@ void rejectsReplayWhenTheRecordedRequestDiffers() { @Test void anInterruptedTurnFailsClosedInsteadOfAskingAgain() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - recorder.recordRequest(new ModelTurnRecorder.Turn(STREAM, "agent:0"), request(FIRST_TURN)); + recorder.recordRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN))) .isInstanceOf(ModelTurnJournalException.class) .hasMessageContaining("never recorded"); - assertThat(journal.find(STREAM, "agent:0#resp")).isEmpty(); + assertThat(journal.find(RUN_ID, "agent:0#resp")).isEmpty(); } @Test void ajournalFailureSurfacesAsAJournalFailure() { - RunJournal failing = new RunJournal() { + RunJournalWriter failing = new RunJournalWriter() { @Override public Optional find(String streamId, String entryId) { return Optional.empty(); } @Override - public List entries(String streamId) { - return List.of(); + public void commit(String runId, RunPayload checkpoint, List entries) { + throw new IllegalStateException("journal unavailable"); } @Override @@ -189,7 +314,7 @@ public void append(String streamId, List entries) { } }; ModelTurnRecorder recorder = ModelTurnRecorder.of(failing); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); assertThatThrownBy(() -> recorder.recordRequest(turn, request(FIRST_TURN))) .isInstanceOf(ModelTurnJournalException.class) @@ -197,51 +322,67 @@ public void append(String streamId, List entries) { } @Test - void aCommittedTurnAddsNoSecondPlanWhenTheProviderRenamesTheCallId() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); - recorder.recordRequest(turn, request(FIRST_TURN)); - recorder.commit(turn, toolCallingResponse("provider-call-1"), TOOLS); + void providerCallIdsDoNotChangeThePlannedTaskIdentity() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + List first = recorder.prepareResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); + List renamed = recorder.prepareResponse(turn, toolCallingResponse("provider-call-2"), TOOLS); + assertThat(renamed.stream().filter(entry -> entry.kind() == RunEntry.Kind.TASK_CALL).toList()) + .isEqualTo(first.stream().filter(entry -> entry.kind() == RunEntry.Kind.TASK_CALL).toList()); - // Recovery replays; it must not append a new task for the renamed call id. - recorder.replay(new ModelTurnRecorder.Turn(STREAM, "agent:0"), request(FIRST_TURN)); + recorder.recordRequest(turn, request(FIRST_TURN)); + journal.commit(RUN_ID, checkpoint(turn), first); + assertThat(recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN))) + .contains(toolCallingResponse("provider-call-1")); + assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); + } - assertThat(journal.entryIds(STREAM)).containsExactly("agent:0#req", "agent:0#resp", "agent:0#task.0"); + @Test + void theSameTaskIdentityWithDifferentArgumentsConflictsWithoutUpdatingCheckpoint() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + commitResponse(turn, toolCallingResponse("call-1"), TOOLS); + RunSnapshot before = journal.snapshot(); + RunEntry changed = new RunEntry(1, "agent:0#task.0", RunEntry.Kind.TASK_CALL, + new RunPayload(1, ModelTurnCodec.PLAN_TYPE, ModelTurnCodec.encodeToolPlan( + new ToolCall("call-2", "search", "{\"q\":\"different\"}"), TOOLS.get(0), 0))); + + assertThatThrownBy(() -> journal.commit(RUN_ID, new RunPayload(1, "test.checkpoint", "{\"changed\":true}"), + List.of(changed))).isInstanceOf(RunStoreConflictException.class); + + assertThat(journal.snapshot()).isSameAs(before); + assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } @Test void streamedTextIsRecordedUnderOneStableAttempt() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:0"); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); String attempt = ModelTurnRecorder.newAttempt(); + RunSnapshot before = journal.snapshot(); recorder.recordDelta(turn, attempt, 0, "he"); recorder.recordDelta(turn, attempt, 1, "llo"); recorder.recordDelta(turn, attempt, 2, ""); - assertThat(journal.entryIds(STREAM)).containsExactly("agent:0#progress." + attempt + "#delta.0", + assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#progress." + attempt + "#delta.0", "agent:0#progress." + attempt + "#delta.1"); + assertThat(journal.snapshot().revision()).isEqualTo(before.revision() + 2); + assertThat(journal.snapshot().checkpoint()).isEqualTo(before.checkpoint()); // Progress is diagnostic only: it never produces a response or a plan. - assertThat(journal.find(STREAM, "agent:0#resp")).isEmpty(); - assertThat(journal.find(STREAM, "agent:0#task.0")).isEmpty(); + assertThat(journal.find(RUN_ID, "agent:0#resp")).isEmpty(); + assertThat(journal.find(RUN_ID, "agent:0#task.0")).isEmpty(); } @Test void differentCallsWithTheSameRequestHaveIndependentRecords() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn first = new ModelTurnRecorder.Turn(STREAM, "first-call"); - ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(STREAM, "next-call"); + ModelTurnRecorder.Turn first = new ModelTurnRecorder.Turn(RUN_ID, "first-call"); + ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(RUN_ID, "next-call"); recorder.recordRequest(first, request(FIRST_TURN)); - recorder.commit(first, toolCallingResponse("call-1"), TOOLS); + commitResponse(first, toolCallingResponse("call-1"), TOOLS); assertThat(recorder.replay(next, request(FIRST_TURN))).isEmpty(); recorder.recordRequest(next, request(FIRST_TURN)); - recorder.commit(next, toolCallingResponse("call-2"), TOOLS); + commitResponse(next, toolCallingResponse("call-2"), TOOLS); - assertThat(journal.entryIds(STREAM)).containsExactly("first-call#req", "first-call#resp", "first-call#task.0", + assertThat(journal.snapshot().journal()).containsOnlyKeys("first-call#req", "first-call#resp", "first-call#task.0", "next-call#req", "next-call#resp", "next-call#task.0"); } @@ -252,12 +393,10 @@ void differentCallsWithTheSameRequestHaveIndependentRecords() { */ @Test void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); for (long ordinal = 0; ordinal < 3; ordinal++) { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "agent:" + ordinal); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:" + ordinal); recorder.recordRequest(turn, request(List.of(ChatMessage.user("question " + ordinal)))); - recorder.commit(turn, toolCallingResponse("call-" + ordinal), TOOLS); + commitResponse(turn, toolCallingResponse("call-" + ordinal), TOOLS); } // Summarisation replaced the history with a summary plus a short tail, so the @@ -265,11 +404,11 @@ void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { // a new identity independently of that history. List summarisedHistory = List.of(ChatMessage.system("summary of earlier turns"), ChatMessage.user("question 3")); - ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(STREAM, "after-summary"); + ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(RUN_ID, "after-summary"); assertThat(recorder.replay(next, request(summarisedHistory))).isEmpty(); recorder.recordRequest(next, request(summarisedHistory)); - assertThat(journal.find(STREAM, "after-summary#req")).isPresent(); + assertThat(journal.find(RUN_ID, "after-summary#req")).isPresent(); } /** @@ -280,18 +419,16 @@ void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { */ @Test void aNonDeterministicRewriteOfTheSameTurnFailsClosed() { - MemoryRunJournal journal = new MemoryRunJournal(); - ModelTurnRecorder recorder = ModelTurnRecorder.of(journal); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(STREAM, "summary-turn"); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "summary-turn"); recorder.recordRequest(turn, request(List.of(ChatMessage.system("summary, first attempt"), ChatMessage.user("question")))); - recorder.commit(turn, toolCallingResponse("call-1"), TOOLS); + commitResponse(turn, toolCallingResponse("call-1"), TOOLS); // After a restart the same hook produced a different summary for the same turn. List rebuilt = List.of(ChatMessage.system("summary, second attempt"), ChatMessage.user("question")); - assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn(STREAM, "summary-turn"), request(rebuilt))) + assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "summary-turn"), request(rebuilt))) .isInstanceOf(ModelTurnJournalException.class) .hasMessageContaining("summarisation"); } @@ -301,6 +438,30 @@ void aDisabledJournalYieldsNoRecorder() { assertThat(ModelTurnRecorder.of(null)).isNull(); } + private void commitResponse(ModelTurnRecorder.Turn turn, ChatResponse response, Iterable tools) { + journal.commit(RUN_ID, checkpoint(turn), recorder.prepareResponse(turn, response, tools)); + } + + private static RunPayload checkpoint(ModelTurnRecorder.Turn turn) { + return new RunPayload(1, "test.checkpoint", + "{\"phase\":\"TOOLS_PENDING\",\"turnId\":\"" + turn.turnId() + "\"}"); + } + + /** Reference ordering only; tool receipts and production engine recovery are separate. */ + private static ChatResponse executeReference(RunJournalWriter writer, ModelTurnRecorder.Turn turn, + ChatRequest request, Supplier model, Consumer dispatch) { + ModelTurnRecorder recorder = ModelTurnRecorder.of(writer); + ChatResponse response = recorder.replay(turn, request).orElseGet(() -> { + recorder.recordRequest(turn, request); + ChatResponse result = model.get(); + List entries = recorder.prepareResponse(turn, result, request.tools()); + writer.commit(turn.streamId(), checkpoint(turn), entries); + return result; + }); + response.toolCalls().forEach(dispatch); + return response; + } + private static ChatRequest request(List messages) { return new ChatRequest(messages, TOOLS, null, Map.of()); } diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java deleted file mode 100644 index 10960f789..000000000 --- a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournal.java +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2025-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.runtime.execution.run; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import static java.util.Objects.requireNonNull; - -/** - * Single-process reference journal. A shared monitor makes each append atomic and - * validate-before-publish: every entry is checked against the recorded stream before any - * of them becomes visible, so a rejected batch leaves the journal unchanged. - * - *

    - * This implementation keeps data only in memory. It is a test and development backend, - * not a durability guarantee; a journal that must survive a JVM must be backed by - * storage and verified against {@code RunJournalContractTest}. - */ -public final class MemoryRunJournal implements RunJournal { - - private final Map> streams = new HashMap<>(); - - @Override - public synchronized Optional find(String streamId, String entryId) { - RunValidation.text(streamId, "streamId"); - RunValidation.text(entryId, "entryId"); - Map stream = streams.get(streamId); - return stream == null ? Optional.empty() : Optional.ofNullable(stream.get(entryId)); - } - - @Override - public synchronized List entries(String streamId) { - RunValidation.text(streamId, "streamId"); - Map stream = streams.get(streamId); - return stream == null ? List.of() : List.copyOf(stream.values()); - } - - @Override - public synchronized void append(String streamId, List entries) { - RunValidation.text(streamId, "streamId"); - requireNonNull(entries, "entries cannot be null"); - List batch = List.copyOf(entries); - for (RunEntry entry : batch) { - requireNonNull(entry, "entries cannot contain null"); - } - Map stream = streams.get(streamId); - // Validate the whole batch first: no partial write may survive a conflict. - for (RunEntry entry : batch) { - RunEntry existing = stream == null ? null : stream.get(entry.id()); - if (existing != null && !existing.equals(entry)) { - throw new RunStoreConflictException( - "Journal entry id identifies different data: " + streamId + "/" + entry.id()); - } - } - if (stream == null) { - stream = new LinkedHashMap<>(); - streams.put(streamId, stream); - } - for (RunEntry entry : batch) { - stream.putIfAbsent(entry.id(), entry); - } - } - - /** Entry count of one stream; a convenience for assertions and diagnostics. */ - public synchronized int size(String streamId) { - Map stream = streams.get(streamId); - return stream == null ? 0 : stream.size(); - } - - /** All entry ids of one stream in append order. */ - public synchronized List entryIds(String streamId) { - Map stream = streams.get(streamId); - return stream == null ? List.of() : new ArrayList<>(stream.keySet()); - } - -} diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java deleted file mode 100644 index c02d61147..000000000 --- a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournal.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2025-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.runtime.execution.run; - -import java.util.List; -import java.util.Optional; - -/** - * Append-only journal for data that cannot be regenerated: model requests and the - * responses and tool plans derived from them. - * - *

    - * This is deliberately narrower than {@link RunStore}. A journal write carries no lease, - * no run state transition and no revision, because it is not a run commit: it records - * what already happened so a recovery can reuse it instead of asking a model again. Use - * {@link RunStore#commit} when checkpoint, task and journal data must advance together - * under an owner fence. - * - *

    - * Ids are caller-assigned and stable across JVMs. Re-appending an id with identical data - * is a no-op; re-appending it with different data is a conflict and nothing is written. - * - * @see RunEntry - */ -public interface RunJournal { - - /** - * Reads one entry of one stream. - * @param streamId caller-owned stream identity, for example a graph thread id - * @param entryId stable entry identity within that stream - * @return the recorded entry, or empty when it was never appended - */ - Optional find(String streamId, String entryId); - - /** Every entry of {@code streamId} in append order; empty when the stream is unknown. */ - List entries(String streamId); - - /** - * Atomically appends every entry or none of them. - * - *

    - * All data is validated before anything is published, so a conflict or a rejected - * payload leaves the journal unchanged. A duplicate id whose data equals the recorded - * entry is accepted, which makes a retried append after an unknown outcome safe. - * @param streamId caller-owned stream identity - * @param entries entries to append in order - * @throws RunStoreConflictException when an id is already recorded with other data - * @throws IllegalArgumentException when an entry is invalid - */ - void append(String streamId, List entries); - -} diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java new file mode 100644 index 000000000..39cd8d6a4 --- /dev/null +++ b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025-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.runtime.execution.run; + +import java.util.List; +import java.util.Optional; + +/** + * Narrow journal access supplied by a runtime that owns the run's submission, lease, + * revision and execution position. Writes use {@link RunStore#commit}, rather than an + * independent journal backend. Engines need not manage storage ownership themselves. + * + *

    + * Entry ids are stable within a run. Identical journal data may be submitted again; + * changed data under the same id is a conflict. Every successful write may advance the + * run revision, including a write containing only previously recorded entries. + */ +public interface RunJournalWriter { + + /** Reads an entry from the runtime's committed view of the run. */ + Optional find(String runId, String entryId); + + /** + * Atomically appends journal entries through a run commit, retaining the current + * checkpoint. Used for request fingerprints and diagnostic progress. + * @throws RunStoreConflictException when the run commit or an entry conflicts + */ + void append(String runId, List entries); + + /** + * Atomically commits the supplied checkpoint and every journal entry. The runtime + * calls this after preparing the corresponding execution position; returning means + * the storage commit has completed, not that the entries have merely been buffered. + * @param runId the run whose checkpoint and journal advance together + * @param checkpoint the non-null checkpoint corresponding to these entries + * @param entries the complete batch to commit + * @throws RunStoreConflictException when the run commit or an entry conflicts + */ + void commit(String runId, RunPayload checkpoint, List entries); + +} diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java new file mode 100644 index 000000000..c188fd649 --- /dev/null +++ b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025-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.runtime.execution.run; + +import java.util.List; +import java.util.Optional; + +import static java.util.Objects.requireNonNull; + +/** + * Reference journal writer bound to a runtime-supplied lease and committed run snapshot. + * The store remains the only persistence backend and supplies all atomicity, fencing and + * revision checks. This adapter neither admits runs nor manages leases or transitions. + * + *

    + * All writes are serialized and use the last successful snapshot's revision. A failed + * commit leaves that view unchanged and is propagated without retry. If the runtime + * advances the run through another path, it must create a new writer from the updated + * snapshot. Reads use the bound view rather than silently loading a newer revision. + */ +public final class RunStoreJournalWriter implements RunJournalWriter { + + private final RunStore store; + + private final RunLease lease; + + private RunSnapshot snapshot; + + public RunStoreJournalWriter(RunStore store, RunLease lease, RunSnapshot snapshot) { + this.store = requireNonNull(store, "store cannot be null"); + this.lease = requireNonNull(lease, "lease cannot be null"); + this.snapshot = requireNonNull(snapshot, "snapshot cannot be null"); + if (!lease.namespace().equals(snapshot.spec().namespace())) { + throw new IllegalArgumentException("lease namespace does not match the run"); + } + } + + @Override + public synchronized Optional find(String runId, String entryId) { + requireRun(runId); + RunValidation.text(entryId, "entryId"); + return Optional.ofNullable(snapshot.journal().get(entryId)); + } + + @Override + public synchronized void append(String runId, List entries) { + write(runId, null, entries); + } + + @Override + public synchronized void commit(String runId, RunPayload checkpoint, List entries) { + write(runId, requireNonNull(checkpoint, "checkpoint cannot be null"), entries); + } + + /** The last successful committed view, for the runtime coordinating this writer. */ + public synchronized RunSnapshot snapshot() { + return snapshot; + } + + private void write(String runId, RunPayload checkpoint, List entries) { + requireRun(runId); + RunCommit commit = new RunCommit(snapshot.revision(), snapshot.state(), checkpoint, List.of(), entries, + List.of()); + snapshot = store.commit(snapshot.spec().namespace(), runId, lease, commit); + } + + private void requireRun(String runId) { + if (!snapshot.spec().runId().equals(runId)) { + throw new IllegalArgumentException("runId does not match the bound run"); + } + } + +} diff --git a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java deleted file mode 100644 index 39e0d6ff5..000000000 --- a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/MemoryRunJournalTest.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2025-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.runtime.execution.run; - -import java.util.List; - -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -/** Contract and reference-backend specifics for {@link MemoryRunJournal}. */ -class MemoryRunJournalTest extends RunJournalContractTest { - - @Override - protected RunJournal newJournal() { - return new MemoryRunJournal(); - } - - @Test - void exposesInsertionOrderedIdsForDiagnostics() { - MemoryRunJournal memory = new MemoryRunJournal(); - memory.append("thread", List.of(entry("a"), entry("b"))); - memory.append("thread", List.of(entry("c"))); - assertThat(memory.entryIds("thread")).containsExactly("a", "b", "c"); - assertThat(memory.size("thread")).isEqualTo(3); - assertThat(memory.size("unknown")).isZero(); - assertThat(memory.entryIds("unknown")).isEmpty(); - } - - @Test - void returnsAnImmutableSnapshotOfAppendedEntries() { - MemoryRunJournal memory = new MemoryRunJournal(); - memory.append("thread", List.of(entry("a"))); - List snapshot = memory.entries("thread"); - memory.append("thread", List.of(entry("b"))); - assertThat(snapshot).hasSize(1); - } - -} diff --git a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java deleted file mode 100644 index db2810b6d..000000000 --- a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunJournalContractTest.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright 2025-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.runtime.execution.run; - -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** - * Reusable semantic contract for {@link RunJournal} adapters. Backend suites should - * extend this fixture and additionally verify restart persistence and atomic visibility - * under real storage. - */ -public abstract class RunJournalContractTest { - - protected RunJournal journal; - - protected abstract RunJournal newJournal(); - - @BeforeEach - void setUp() { - journal = newJournal(); - } - - @Test - void unknownStreamAndEntryReadAsAbsent() { - assertThat(journal.find("thread", "a")).isEmpty(); - assertThat(journal.entries("thread")).isEmpty(); - } - - @Test - void preservesAppendOrderAcrossBatches() { - journal.append("thread", List.of(entry("a"), entry("b"))); - journal.append("thread", List.of(entry("c"))); - assertThat(journal.entries("thread")).extracting(RunEntry::id).containsExactly("a", "b", "c"); - } - - @Test - void reappendingIdenticalDataIsIdempotent() { - RunEntry first = entry("a"); - journal.append("thread", List.of(first)); - journal.append("thread", List.of(entry("a"))); - assertThat(journal.entries("thread")).containsExactly(first); - } - - @Test - void rejectsSameIdWithDifferentDataWithoutPartialWrite() { - journal.append("thread", List.of(entry("a"))); - assertThatThrownBy(() -> journal.append("thread", List.of(entry("b"), entry("a", "{\"value\":2}")))) - .isInstanceOf(RunStoreConflictException.class); - assertThat(journal.find("thread", "b")).isEmpty(); - assertThat(journal.entries("thread")).hasSize(1); - } - - @Test - void separatesStreamsWithIndependentIds() { - journal.append("one", List.of(entry("a", "{\"value\":1}"))); - journal.append("two", List.of(entry("a", "{\"value\":2}"))); - assertThat(journal.find("one", "a")).hasValueSatisfying( - value -> assertThat(value.payload().json()).contains("\"value\":1")); - assertThat(journal.find("two", "a")).hasValueSatisfying( - value -> assertThat(value.payload().json()).contains("\"value\":2")); - } - - @Test - void rejectsBlankIdentitiesAndNullEntries() { - assertThatThrownBy(() -> journal.append(" ", List.of(entry("a")))).isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> journal.find("thread", " ")).isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> journal.entries(" ")).isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> journal.append("thread", List.of((RunEntry) null))) - .isInstanceOf(NullPointerException.class); - } - - protected static RunEntry entry(String id) { - return entry(id, "{\"value\":1}"); - } - - protected static RunEntry entry(String id, String json) { - return new RunEntry(1, id, RunEntry.Kind.MODEL_CALL, new RunPayload(1, "test.payload", json)); - } - -} diff --git a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java new file mode 100644 index 000000000..3c6abbec6 --- /dev/null +++ b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java @@ -0,0 +1,208 @@ +/* + * Copyright 2025-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.runtime.execution.run; + +import io.github.agentic.ai.runtime.execution.PrincipalReference; +import io.github.agentic.ai.runtime.execution.TenantReference; +import io.github.agentic.ai.runtime.execution.TrustedExecutionContext; +import io.github.agentic.ai.runtime.execution.definition.DefinitionKind; +import io.github.agentic.ai.runtime.execution.definition.DefinitionReference; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Adapter behavior; backend atomicity and fencing remain in RunStoreContractTest. */ +@Timeout(10) +class RunStoreJournalWriterTest { + + private static final String RUN_ID = "run-1"; + + private final RunStore store = new MemoryRunStore(); + + private RunSpec run; + + private RunLease lease; + + private RunStoreJournalWriter writer; + + @BeforeEach + void setUp() { + run = spec(RUN_ID, DefinitionKind.AGENT); + store.submit(run, context(RUN_ID)); + lease = store.acquire(run.namespace(), RUN_ID, UUID.randomUUID(), Duration.ofMinutes(1)); + RunSnapshot running = store.commit(run.namespace(), RUN_ID, lease, + new RunCommit(0, RunState.RUNNING, checkpoint("MODEL_READY"), List.of(), List.of(), + List.of(entry("started", RunEntry.Kind.EVENT, "{}")))); + writer = new RunStoreJournalWriter(store, lease, running); + } + + @Test + void appendRetainsCheckpointStateAndOtherRunData() { + RunSnapshot before = writer.snapshot(); + RunEntry request = entry("turn#req", RunEntry.Kind.MODEL_CALL, "{\"hash\":\"request\"}"); + RunEntry delta = entry("turn#delta.0", RunEntry.Kind.EVENT, "{\"text\":\"hello\"}"); + + writer.append(RUN_ID, List.of(request)); + writer.append(RUN_ID, List.of(delta)); + + RunSnapshot after = writer.snapshot(); + assertThat(after.revision()).isEqualTo(before.revision() + 2); + assertThat(after.checkpoint()).isEqualTo(before.checkpoint()); + assertThat(after.state()).isEqualTo(before.state()); + assertThat(after.tasks()).isEqualTo(before.tasks()); + assertThat(after.outbox()).isEqualTo(before.outbox()); + assertThat(after.journal()).containsOnlyKeys(request.id(), delta.id()); + assertThat(writer.find(RUN_ID, request.id())).contains(request); + assertThat(writer.find(RUN_ID, "missing")).isEmpty(); + assertThat(store.find(run.namespace(), RUN_ID)).contains(after); + } + + @Test + void commitsCheckpointAndWholePlanInOneRevision() { + RunSnapshot before = writer.snapshot(); + RunPayload checkpoint = checkpoint("TOOLS_PENDING"); + List entries = List.of(entry("turn#resp", RunEntry.Kind.MODEL_CALL, "{}"), + entry("turn#task.0", RunEntry.Kind.TASK_CALL, "{\"ordinal\":0}"), + entry("turn#task.1", RunEntry.Kind.TASK_CALL, "{\"ordinal\":1}")); + + writer.commit(RUN_ID, checkpoint, entries); + + RunSnapshot after = store.find(run.namespace(), RUN_ID).orElseThrow(); + assertThat(after).isEqualTo(writer.snapshot()); + assertThat(after.revision()).isEqualTo(before.revision() + 1); + assertThat(after.checkpoint()).isEqualTo(checkpoint); + assertThat(after.journal().values()).containsExactlyInAnyOrderElementsOf(entries); + assertThat(after.state()).isEqualTo(before.state()); + assertThat(after.tasks()).isEqualTo(before.tasks()); + assertThat(after.outbox()).isEqualTo(before.outbox()); + } + + @Test + void conflictingBatchLeavesCheckpointAndCommittedViewUnchanged() { + writer.append(RUN_ID, List.of(entry("turn#task.0", RunEntry.Kind.TASK_CALL, "{\"input\":1}"))); + RunSnapshot before = writer.snapshot(); + + assertThatThrownBy(() -> writer.commit(RUN_ID, checkpoint("TOOLS_PENDING"), + List.of(entry("turn#resp", RunEntry.Kind.MODEL_CALL, "{}"), + entry("turn#task.0", RunEntry.Kind.TASK_CALL, "{\"input\":2}")))) + .isInstanceOf(RunStoreConflictException.class); + + assertThat(writer.snapshot()).isSameAs(before); + assertThat(store.find(run.namespace(), RUN_ID)).contains(before); + assertThat(writer.find(RUN_ID, "turn#resp")).isEmpty(); + } + + @Test + void externalProgressRequiresExplicitRebindingInsteadOfRetryingWithANewerRevision() { + RunSnapshot before = writer.snapshot(); + RunEntry external = entry("external", RunEntry.Kind.EVENT, "{}"); + RunSnapshot progressed = store.commit(run.namespace(), RUN_ID, lease, + new RunCommit(before.revision(), RunState.RUNNING, checkpoint("TOOLS_COMPLETED"), List.of(), + List.of(external), List.of())); + + assertThatThrownBy(() -> writer.commit(RUN_ID, checkpoint("TOOLS_PENDING"), + List.of(entry("turn#resp", RunEntry.Kind.MODEL_CALL, "{}")))) + .isInstanceOf(RunStoreConflictException.class); + assertThat(writer.snapshot()).isSameAs(before); + assertThat(writer.find(RUN_ID, external.id())).isEmpty(); + assertThat(store.find(run.namespace(), RUN_ID)).contains(progressed); + + RunStoreJournalWriter rebound = new RunStoreJournalWriter(store, lease, progressed); + assertThat(rebound.find(RUN_ID, external.id())).contains(external); + rebound.append(RUN_ID, List.of(entry("next#req", RunEntry.Kind.MODEL_CALL, "{}"))); + assertThat(rebound.snapshot().revision()).isEqualTo(progressed.revision() + 1); + assertThat(rebound.snapshot().checkpoint()).isEqualTo(progressed.checkpoint()); + } + + @Test + void serializesConcurrentAppendsUsingEachSuccessfulRevision() throws Exception { + long revision = writer.snapshot().revision(); + var executor = Executors.newFixedThreadPool(4); + try { + List> writes = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + String id = "delta-" + i; + writes.add(() -> { + writer.append(RUN_ID, List.of(entry(id, RunEntry.Kind.EVENT, "{}"))); + return null; + }); + } + for (var result : executor.invokeAll(writes)) { + result.get(5, TimeUnit.SECONDS); + } + } + finally { + executor.shutdownNow(); + } + assertThat(writer.snapshot().revision()).isEqualTo(revision + 12); + assertThat(writer.snapshot().journal()).hasSize(12); + assertThat(store.find(run.namespace(), RUN_ID)).contains(writer.snapshot()); + } + + @Test + void acceptsBothGraphAndIndependentAgentRunDefinitions() { + for (DefinitionKind kind : List.of(DefinitionKind.GRAPH, DefinitionKind.AGENT)) { + String runId = kind.wireName() + "-run"; + RunSpec spec = spec(runId, kind); + RunSnapshot admitted = store.submit(spec, context(runId)); + RunLease owned = store.acquire(spec.namespace(), runId, UUID.randomUUID(), Duration.ofMinutes(1)); + RunStoreJournalWriter bound = new RunStoreJournalWriter(store, owned, admitted); + RunEntry entry = entry("turn#req", RunEntry.Kind.MODEL_CALL, "{}"); + + bound.append(runId, List.of(entry)); + + assertThat(bound.find(runId, entry.id())).contains(entry); + assertThat(bound.snapshot().spec().definition().kind()).isEqualTo(kind); + assertThat(store.find(spec.namespace(), runId)).contains(bound.snapshot()); + } + } + + private static RunPayload checkpoint(String phase) { + return new RunPayload(1, "test.checkpoint", "{\"phase\":\"" + phase + "\"}"); + } + + private static RunEntry entry(String id, RunEntry.Kind kind, String json) { + return new RunEntry(1, id, kind, new RunPayload(1, "test.journal", json)); + } + + private static RunSpec spec(String runId, DefinitionKind kind) { + return RunSpec.from(context(runId), "app", runId, "submission-" + runId, + DefinitionReference.of(kind, "definition", "1"), new RunPayload(1, "test.input", "{}")); + } + + private static TrustedExecutionContext context(String runId) { + return TrustedExecutionContext.builder() + .tenant(new TenantReference("iam", "tenant")) + .principal(new PrincipalReference("iam", "user")) + .runId(runId) + .correlationId("trace") + .build(); + } + +} From a5b5e2d1740644d5695bd1fda017fc8d0b505448 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Thu, 29 Oct 2026 20:57:39 +0800 Subject: [PATCH 06/12] refactor(agent): make model-turn journaling stateless --- .../durable/ModelTurnJournalException.java | 8 +- .../ai/agent/durable/ModelTurnRecorder.java | 247 ++++--- .../ai/agent/durable/package-info.java | 35 +- .../agent/durable/ModelTurnRecorderTest.java | 603 ++++++++++++++---- .../execution/run/RunJournalWriter.java | 55 -- .../execution/run/RunStoreJournalWriter.java | 87 --- .../run/RunStoreJournalWriterTest.java | 208 ------ 7 files changed, 671 insertions(+), 572 deletions(-) delete mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java delete mode 100644 argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java delete mode 100644 argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java index 36fb4af18..dc86210f4 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java @@ -16,14 +16,14 @@ package io.github.agentic.ai.agent.durable; /** - * A model-turn payload could not be encoded or decoded, a request/progress append failed, - * or recorded data does not describe the request being issued. Runtime commits through - * RunJournalWriter retain the original RunStore exception types. + * A model-turn payload could not be encoded or decoded, or recorded data is incomplete, + * incompatible or does not describe the run and request being replayed. This component + * performs no storage writes; runtime commits retain the original RunStore exception types. * *

    * This is not a model failure. An engine that converts caught model exceptions into * user-visible error messages must rethrow this type instead. The runtime must also - * propagate writer commit failures before dispatching tools. + * propagate storage commit failures before dispatching tools. */ public class ModelTurnJournalException extends IllegalStateException { diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java index 05b2f9920..6f78a1c3a 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -20,8 +20,9 @@ import io.github.agentic.ai.api.chat.ToolCall; import io.github.agentic.ai.api.chat.ToolDefinition; import io.github.agentic.ai.runtime.execution.run.RunEntry; -import io.github.agentic.ai.runtime.execution.run.RunJournalWriter; import io.github.agentic.ai.runtime.execution.run.RunPayload; +import io.github.agentic.ai.runtime.execution.run.RunSnapshot; +import io.github.agentic.ai.runtime.execution.run.RunStore; import io.github.agentic.ai.runtime.execution.run.RunStoreConflictException; import java.util.ArrayList; @@ -34,71 +35,51 @@ import static java.util.Objects.requireNonNull; /** - * Records the non-deterministic part of a model turn and replays it on recovery. + * Prepares journal data for a model turn and validates recorded results on recovery. * *

    - * This component is engine-neutral: it knows the neutral chat contracts and the journal, - * not graphs, streams, checkpoints or schedulers. An engine supplies a {@link Turn} for the - * logical model call, then records streamed text as it arrives and prepares the terminal - * response and tool plan. The runtime commits those records with the corresponding - * checkpoint through {@link RunJournalWriter#commit} before dispatching any tool. - * - *

    - * The engine owns turn identities and their recovery: the same logical call must retain - * its identity across retries and restarts, and different calls within a run must have - * different turn ids. Summarisation and history trimming do not change that identity. - * The recorder uses the supplied ids without allocating or advancing execution positions. - * - *

    - * The request fingerprint is taken from the request the provider actually receives, after - * every request-mutating interceptor and hook. A component that rewrites the request - * non-deterministically - for example summarisation that calls a model to produce the - * summary - can rebuild a different request for the same turn after a restart. Such a turn - * fails closed with a conflict rather than replaying a response produced for other input; - * it is not recovered automatically. Applications that need automatic recovery across such - * a rewrite must make the rewrite deterministic or disable it while journaling. + * Stateless: every preparation method returns immutable records and writes nothing. The + * runtime owns the store, lease, revision and committed snapshot, and decides whether + * journaling is in use at all. It commits the request fingerprint before calling the + * provider, and the response with all its plans and the checkpoint in one + * {@link RunStore#commit} before dispatching a tool. Store exceptions propagate unchanged; + * they are not model failures. * *

    * Entry ids of one turn: *

      - *
    • {@code #req} - the request fingerprint, written before the call
    • - *
    • {@code #progress.#delta.} - streamed text, diagnostic - * only
    • + *
    • {@code #req} - the request fingerprint
    • + *
    • {@code #progress.#delta.} - streamed text, diagnostic only
    • *
    • {@code #resp} - the complete terminal response
    • - *
    • {@code #task.} - the tool plan, committed atomically with the - * response and checkpoint by the runtime
    • + *
    • {@code #task.} - one planned tool call
    • *
    * *

    - * An interrupted turn is never re-asked: a recorded request without a recorded response is - * evidence that its outcome is unknown, whether or not the provider call was issued. - * Repeating it would make cost and outcome depend on how often a worker restarted. - * Budgeted re-asking belongs to the retry path. + * An interrupted turn is never re-asked: a request without a response means its outcome is + * unknown, and budgeted re-asking belongs to the retry path. */ public final class ModelTurnRecorder { - private final RunJournalWriter journal; + private static final int ENTRY_SCHEMA_VERSION = 1; - private ModelTurnRecorder(RunJournalWriter journal) { - this.journal = requireNonNull(journal, "journal cannot be null"); - } + private static final String PLAN_PREFIX = "#task."; - /** Builds a recorder, or returns {@code null} when journaling is off. */ - public static ModelTurnRecorder of(RunJournalWriter journal) { - return journal == null ? null : new ModelTurnRecorder(journal); + /** Creates a stateless recorder; the runtime decides whether to use journaling. */ + public ModelTurnRecorder() { } /** * Engine-supplied identity of one logical model call. The engine restores the same * identity on recovery and assigns distinct turn ids to different calls within a run. - * @param streamId the run id bound to the runtime's journal writer + * @param runId the run to which the prepared records belong * @param turnId the stable logical call identity within that run */ - public record Turn(String streamId, String turnId) { + public record Turn(String runId, String turnId) { public Turn { - requireNonNull(streamId, "streamId cannot be null"); - requireNonNull(turnId, "turnId cannot be null"); + if (runId == null || runId.isBlank() || turnId == null || turnId.isBlank()) { + throw new IllegalArgumentException("runId and turnId must not be blank"); + } } } @@ -108,52 +89,93 @@ public record Turn(String streamId, String turnId) { * it. * *

    - * A recorded request whose fingerprint differs fails closed: replaying it would hand the - * engine a plan the model never produced for this input. This is also how a - * non-deterministic request rewrite surfaces after a restart; see the class - * documentation. A recorded request without a recorded response also fails closed, - * because the outcome is unknown rather than missing. + * Reads only the supplied snapshot. A response requires its matching request and exactly + * the tool plans described by that response and request. Orphaned or incompatible data + * fails closed. These checks validate current contents, not historical commit boundaries + * or the meaning of an application-owned checkpoint. + * + *

    + * The fingerprint covers the request the provider actually received, after every + * request-mutating interceptor. A rewrite that differs on the second call - for example + * summarisation that asks a model for its summary - therefore fails closed here instead + * of replaying a response produced for other input. Applications that need automatic + * recovery across such a rewrite must make it deterministic while journaling. + * + *

    + * This method cannot detect snapshot staleness. After an empty result, the runtime must + * commit the request using this snapshot's revision before calling the model. A conflict + * requires reloading and repeating replay, not merely retrying with a newer revision. * @param turn the engine-supplied identity of this logical call * @param request the request about to be issued - * @return the recorded response, or empty when the model has not been called yet - * @throws ModelTurnJournalException when the recorded request differs, or when a turn - * was issued but its response was never recorded + * @param snapshot the runtime's consistent committed view of the run + * @return the recorded response, or empty when no request, response or plan is recorded + * @throws ModelTurnJournalException when recorded data is incomplete, incompatible or + * does not match the run or request */ - public Optional replay(Turn turn, ChatRequest request) { - Optional recorded = journal.find(turn.streamId(), turn.turnId() + "#req"); - if (recorded.isPresent()) { - String hash = ModelTurnCodec.decodeRequestHash(recorded.get().payload().json()); - if (!hash.equals(ModelTurnCodec.requestHash(request))) { - throw new ModelTurnJournalException("Model turn " + turn.turnId() - + " was recorded for a different request; refusing to replay it. This happens when the" - + " request is rewritten non-deterministically, for example by summarisation, or when" - + " the engine reuses a turn identity", - new RunStoreConflictException( - "Journal entry id identifies different data: " + turn.streamId() + "/" + turn.turnId())); - } + public Optional replay(Turn turn, ChatRequest request, RunSnapshot snapshot) { + requireNonNull(turn, "turn cannot be null"); + requireNonNull(request, "request cannot be null"); + requireNonNull(snapshot, "snapshot cannot be null"); + if (!turn.runId().equals(snapshot.spec().runId())) { + throw new ModelTurnJournalException("Run snapshot does not match model turn " + turn.turnId()); } - Optional response = journal.find(turn.streamId(), turn.turnId() + "#resp"); - if (response.isPresent()) { - return response.map(entry -> ModelTurnCodec.decodeResponse(entry.payload().json())); + RunEntry recorded = snapshot.journal().get(turn.turnId() + "#req"); + RunEntry response = snapshot.journal().get(turn.turnId() + "#resp"); + Map plans = recordedPlans(turn, snapshot); + if (recorded == null && response == null && plans.isEmpty()) { + return Optional.empty(); } - if (recorded.isPresent()) { + if (recorded == null) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + " is missing its request fingerprint"); + } + validateEntry(recorded, turn.turnId() + "#req", RunEntry.Kind.MODEL_CALL, ModelTurnCodec.REQUEST_TYPE); + String hash = ModelTurnCodec.decodeRequestHash(recorded.payload().json()); + if (hash == null || hash.isBlank()) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + " has an invalid request fingerprint"); + } + if (!hash.equals(ModelTurnCodec.requestHash(request))) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + + " was recorded for a different request; refusing to replay it. This happens when the" + + " request is rewritten non-deterministically, for example by summarisation, or when" + + " the engine reuses a turn identity", + new RunStoreConflictException( + "Journal entry id identifies different data: " + turn.runId() + "/" + turn.turnId())); + } + if (response == null) { throw new ModelTurnJournalException("Model turn " + turn.turnId() + " was issued but its response was never recorded; the turn cannot be replayed automatically"); } - return Optional.empty(); + validateEntry(response, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, ModelTurnCodec.RESPONSE_TYPE); + ChatResponse decoded = ModelTurnCodec.decodeResponse(response.payload().json()); + List expected = preparePlans(turn, decoded, request.tools()); + if (plans.size() != expected.size()) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + " has an incomplete or extra tool plan"); + } + for (RunEntry planned : expected) { + RunEntry actual = plans.get(planned.id()); + if (actual == null) { + throw new ModelTurnJournalException("Missing tool plan entry " + planned.id()); + } + validateEntry(actual, planned.id(), RunEntry.Kind.TASK_CALL, ModelTurnCodec.PLAN_TYPE); + if (!actual.equals(planned)) { + throw new ModelTurnJournalException("Recorded tool plan does not match the response and request: " + + planned.id()); + } + } + return Optional.of(decoded); } - /** Records the request fingerprint before the provider call is issued. */ - public void recordRequest(Turn turn, ChatRequest request) { - append(turn, List.of(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, turn.turnId() + "#req", + /** Prepares the request fingerprint; the runtime commits it before calling the provider. */ + public List prepareRequest(Turn turn, ChatRequest request) { + return List.of(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + "#req", RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.REQUEST_TYPE, - ModelTurnCodec.encodeRequest(request))))); + ModelTurnCodec.encodeRequest(request)))); } /** * Prepares the terminal response and every tool plan without publishing any records. * The runtime must commit this entire batch with the corresponding checkpoint through - * {@link RunJournalWriter#commit} before dispatching the first tool. Preparing the batch + * {@link RunStore#commit} before dispatching the first tool. Preparing the batch * does not authorize dispatch. * @param turn the turn being prepared * @param response the terminal response the engine is about to use @@ -163,26 +185,15 @@ RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTur */ public List prepareResponse(Turn turn, ChatResponse response, Iterable tools) { List entries = new ArrayList<>(); - entries.add(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, + entries.add(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.RESPONSE_TYPE, ModelTurnCodec.encodeResponse(response)))); - Map byName = new HashMap<>(); - if (tools != null) { - tools.forEach(definition -> byName.put(definition.name(), definition)); - } - int ordinal = 0; - for (ToolCall call : response.toolCalls()) { - entries.add(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, turn.turnId() + "#task." + ordinal, - RunEntry.Kind.TASK_CALL, - new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.PLAN_TYPE, - ModelTurnCodec.encodeToolPlan(call, byName.get(call.name()), ordinal)))); - ordinal++; - } + entries.addAll(preparePlans(turn, response, tools)); return List.copyOf(entries); } /** - * Records text received while a response is still streaming. + * Prepares diagnostic text received while a response is still streaming. * *

    * These entries are diagnostic: they explain how far a lost stream got. They are never @@ -190,18 +201,22 @@ public List prepareResponse(Turn turn, ChatResponse response, Iterable * trigger tool execution. The attempt identifier keeps text from an abandoned attempt * from colliding with a later one, and is stable for every chunk of one attempt so the * recorded text stays ordered. + * + *

    + * The runtime submits these through its single commit coordinator, retaining the + * checkpoint, and handles their completion before committing the terminal response. * @param attempt the attempt token obtained from {@link #newAttempt()} * @param seq the chunk ordinal within this attempt * @param text the text delta received */ - public void recordDelta(Turn turn, String attempt, int seq, String text) { + public List prepareDelta(Turn turn, String attempt, int seq, String text) { if (text == null || text.isEmpty()) { - return; + return List.of(); } - append(turn, List.of(new RunEntry(ModelTurnCodec.SCHEMA_VERSION, + return List.of(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + "#progress." + attempt + "#delta." + seq, RunEntry.Kind.EVENT, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.DELTA_TYPE, - ModelTurnCodec.encodeDelta(text))))); + ModelTurnCodec.encodeDelta(text)))); } /** @@ -213,15 +228,53 @@ public static String newAttempt() { return UUID.randomUUID().toString(); } - private void append(Turn turn, List entries) { - try { - journal.append(turn.streamId(), entries); + private List preparePlans(Turn turn, ChatResponse response, Iterable tools) { + List entries = new ArrayList<>(); + Map byName = new HashMap<>(); + if (tools != null) { + tools.forEach(definition -> byName.put(definition.name(), definition)); } - catch (ModelTurnJournalException already) { - throw already; + int ordinal = 0; + for (ToolCall call : response.toolCalls()) { + entries.add(new RunEntry(ENTRY_SCHEMA_VERSION, planId(turn, ordinal), + RunEntry.Kind.TASK_CALL, + new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.PLAN_TYPE, + ModelTurnCodec.encodeToolPlan(call, byName.get(call.name()), ordinal)))); + ordinal++; } - catch (RuntimeException failure) { - throw new ModelTurnJournalException("Failed to journal model turn " + turn.turnId(), failure); + return entries; + } + + private static String planId(Turn turn, int ordinal) { + return turn.turnId() + PLAN_PREFIX + ordinal; + } + + /** + * Plan entries of this turn, keyed by id. Any entry under the turn's plan prefix counts, + * so an extra entry is visible as a size mismatch rather than being silently ignored. + * The suffix must be digits: a turn id may itself contain {@code #task.}, and without + * that check another turn's entries would leak into this one. + */ + private Map recordedPlans(Turn turn, RunSnapshot snapshot) { + String prefix = turn.turnId() + PLAN_PREFIX; + Map plans = new HashMap<>(); + snapshot.journal().forEach((id, entry) -> { + if (id.startsWith(prefix) && isOrdinal(id.substring(prefix.length()))) { + plans.put(id, entry); + } + }); + return plans; + } + + private static boolean isOrdinal(String suffix) { + return !suffix.isEmpty() && suffix.chars().allMatch(c -> c >= '0' && c <= '9'); + } + + private void validateEntry(RunEntry entry, String id, RunEntry.Kind kind, String type) { + if (!id.equals(entry.id()) || entry.schemaVersion() != ENTRY_SCHEMA_VERSION || entry.kind() != kind + || !type.equals(entry.payload().type()) + || entry.payload().schemaVersion() != ModelTurnCodec.SCHEMA_VERSION) { + throw new ModelTurnJournalException("Incompatible journal entry " + id); } } diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java index dc7754a49..89ca3aba9 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java @@ -20,8 +20,9 @@ *

    * The non-deterministic result of a model call — the assistant message, its tool calls, * finish reason and usage — cannot be regenerated. These types record it, together with a - * stable identity for every planned tool call, before the engine dispatches tools. A - * recovery then replays the recorded plan instead of asking the model again. + * stable identity for every planned tool call. Preparation is stateless and has no storage + * side effects. The runtime persists the data before dispatching tools; recovery then + * validates and replays the recorded result instead of asking the model again. * *

    * The contract deliberately knows no graph, session, scheduler or streaming library: it @@ -31,13 +32,27 @@ * and gives different calls distinct turn ids within a run. * *

    - * {@link io.github.agentic.ai.agent.durable.ModelTurnRecorder#prepareResponse} only prepares - * records. The runtime must commit the full batch together with the corresponding - * checkpoint through {@link io.github.agentic.ai.runtime.execution.run.RunJournalWriter#commit} - * before dispatching the first tool. Request fingerprints and diagnostic deltas use the - * same writer's append path, which retains the checkpoint. Both paths delegate to - * {@link io.github.agentic.ai.runtime.execution.run.RunStore#commit}; no independent - * journal backend is required. Planned TASK_CALL entries are journal data, not task - * lifecycle updates in {@code RunCommit.tasks}. + * The runtime explicitly enables journaling and owns the store, lease and latest committed + * snapshot. It commits a prepared request fingerprint before calling the model, then + * commits the full response and plan batch together with the corresponding checkpoint + * through {@link io.github.agentic.ai.runtime.execution.run.RunStore#commit} before + * dispatching the first tool. Planned TASK_CALL entries are journal data, not task lifecycle + * updates in {@code RunCommit.tasks}. + * + *

    + * All writes share one runtime coordinator. Request fingerprints and diagnostic deltas + * retain the checkpoint. The runtime settles in-flight delta commits before submitting the + * terminal response, may drop unsubmitted deltas, and propagates storage failures by default. + * An uncertain commit outcome requires resolving the stored state before continuing from a + * cached snapshot; revision conflicts and ownership loss must not be ignored. + * + *

    + * Replay checks a single supplied snapshot for compatible request/response records and an + * exact matching tool plan. It cannot prove that records were committed together, interpret + * application checkpoints or detect stale reads. An empty replay must be followed by a + * request commit using that snapshot's revision; a conflict requires reloading and replaying + * again. Durable-mode admission, definition and identity recovery, execution positions and + * tool receipts remain runtime responsibilities. An in-memory reference store only models + * recovery within one process. */ package io.github.agentic.ai.agent.durable; diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index ff824cd4e..2e5c61ba3 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -30,20 +30,23 @@ import io.github.agentic.ai.runtime.execution.run.MemoryRunStore; import io.github.agentic.ai.runtime.execution.run.RunCommit; import io.github.agentic.ai.runtime.execution.run.RunEntry; -import io.github.agentic.ai.runtime.execution.run.RunJournalWriter; import io.github.agentic.ai.runtime.execution.run.RunLease; +import io.github.agentic.ai.runtime.execution.run.RunNamespace; import io.github.agentic.ai.runtime.execution.run.RunPayload; import io.github.agentic.ai.runtime.execution.run.RunSnapshot; import io.github.agentic.ai.runtime.execution.run.RunSpec; import io.github.agentic.ai.runtime.execution.run.RunState; +import io.github.agentic.ai.runtime.execution.run.RunStore; +import io.github.agentic.ai.runtime.execution.run.RunStoreCapabilities; import io.github.agentic.ai.runtime.execution.run.RunStoreConflictException; -import io.github.agentic.ai.runtime.execution.run.RunStoreJournalWriter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -72,7 +75,9 @@ class ModelTurnRecorderTest { private RunLease lease; - private RunStoreJournalWriter journal; + private RunStore runtimeStore = store; + + private RunSnapshot snapshot; private ModelTurnRecorder recorder; @@ -88,25 +93,30 @@ void setUp() { DefinitionReference.of(DefinitionKind.AGENT, "agent", "1"), new RunPayload(1, "test.input", "{}")); store.submit(run, context); lease = store.acquire(run.namespace(), RUN_ID, UUID.randomUUID(), Duration.ofMinutes(1)); - RunSnapshot running = store.commit(run.namespace(), RUN_ID, lease, + snapshot = store.commit(run.namespace(), RUN_ID, lease, new RunCommit(0, RunState.RUNNING, new RunPayload(1, "test.checkpoint", "{\"phase\":\"MODEL_READY\"}"), List.of(), List.of(), List.of())); - journal = new RunStoreJournalWriter(store, lease, running); - recorder = ModelTurnRecorder.of(journal); + recorder = new ModelTurnRecorder(); } @Test - void recordsOnlyTheRequestFingerprintBeforeTheCall() { + void preparesOnlyTheRequestFingerprintWithoutWriting() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunSnapshot before = snapshot; - recorder.recordRequest(turn, request(FIRST_TURN)); + List entries = recorder.prepareRequest(turn, request(FIRST_TURN)); - RunEntry recorded = journal.find(RUN_ID, "agent:0#req").orElseThrow(); + assertThat(entries).hasSize(1); + assertThatThrownBy(entries::clear).isInstanceOf(UnsupportedOperationException.class); + RunEntry recorded = entries.get(0); + assertThat(recorded.id()).isEqualTo("agent:0#req"); + assertThat(recorded.schemaVersion()).isEqualTo(1); assertThat(recorded.kind()).isEqualTo(RunEntry.Kind.MODEL_CALL); assertThat(recorded.payload().type()).isEqualTo(ModelTurnCodec.REQUEST_TYPE); // The request body is never persisted: only the fingerprint is. assertThat(recorded.payload().json()).contains("requestHash").doesNotContain("search for argi"); + assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } @Test @@ -151,28 +161,29 @@ void equivalentToolArgumentsInHistoryDoNotChangeTheRequestFingerprint() { @Test void preparingTheResponseReturnsAnImmutableBatchWithoutWriting() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunSnapshot before = journal.snapshot(); + RunSnapshot before = snapshot; List entries = recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS); assertThat(entries).extracting(RunEntry::id).containsExactly("agent:0#resp", "agent:0#task.0"); assertThatThrownBy(entries::clear).isInstanceOf(UnsupportedOperationException.class); - assertThat(journal.snapshot()).isSameAs(before); + assertThat(snapshot).isSameAs(before); assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } @Test void commitsTheResponseAndItsPlanWithCheckpoint() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - long revision = journal.snapshot().revision(); + commitRequest(turn, request(FIRST_TURN)); + long revision = snapshot.revision(); commitResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); - assertThat(journal.snapshot().revision()).isEqualTo(revision + 1); - assertThat(journal.snapshot().checkpoint()).isEqualTo(checkpoint(turn)); - assertThat(journal.snapshot().tasks()).isEmpty(); - assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#resp", "agent:0#task.0"); - RunEntry plan = journal.find(RUN_ID, "agent:0#task.0").orElseThrow(); + assertThat(snapshot.revision()).isEqualTo(revision + 1); + assertThat(snapshot.checkpoint()).isEqualTo(checkpoint(turn)); + assertThat(snapshot.tasks()).isEmpty(); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); + RunEntry plan = find("agent:0#task.0").orElseThrow(); assertThat(plan.kind()).isEqualTo(RunEntry.Kind.TASK_CALL); assertThat(plan.payload().type()).isEqualTo(ModelTurnCodec.PLAN_TYPE); assertThat(plan.payload().json()).contains("\"toolName\":\"search\"", "\"ordinal\":0", "\"argumentsHash\""); @@ -183,7 +194,7 @@ void commitsTheResponseAndItsPlanWithCheckpoint() { @Test void referenceExecutionDispatchesOnlyAfterTheWholePlanAndCheckpointAreCommitted() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - long revision = journal.snapshot().revision(); + long revision = snapshot.revision(); AtomicInteger modelCalls = new AtomicInteger(); AtomicInteger toolCalls = new AtomicInteger(); List calls = List.of(new ToolCall("provider-1", "search", "{\"q\":\"first\"}"), @@ -191,7 +202,7 @@ void referenceExecutionDispatchesOnlyAfterTheWholePlanAndCheckpointAreCommitted( ChatResponse response = new ChatResponse("response", "model", List.of(ChatMessage.assistant("", calls)), calls, FinishReason.TOOL_CALLS, null, Map.of()); - executeReference(journal, turn, request(FIRST_TURN), () -> { + executeReference(turn, request(FIRST_TURN), () -> { modelCalls.incrementAndGet(); return response; }, call -> { @@ -205,56 +216,119 @@ void referenceExecutionDispatchesOnlyAfterTheWholePlanAndCheckpointAreCommitted( assertThat(modelCalls.get()).isEqualTo(1); assertThat(toolCalls.get()).isEqualTo(2); - assertThat(journal.snapshot().revision()).isEqualTo(revision + 2); + assertThat(snapshot.revision()).isEqualTo(revision + 2); } @Test - void referenceExecutionDoesNotDispatchAnyToolWhenCommitFails() { - IllegalStateException unavailable = new IllegalStateException("storage unavailable"); - RunJournalWriter failing = new RunJournalWriter() { - @Override - public Optional find(String runId, String entryId) { - return journal.find(runId, entryId); - } + void requestCommitFailurePreventsModelAndToolCalls() { + IllegalStateException unavailable = new IllegalStateException("request storage unavailable"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.REQUEST_TYPE, false, unavailable); + RunSnapshot before = snapshot; + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); - @Override - public void append(String runId, List entries) { - journal.append(runId, entries); - } + assertThatThrownBy(() -> executeReference(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), + request(FIRST_TURN), () -> { + modelCalls.incrementAndGet(); + return toolCallingResponse("call-1"); + }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); - @Override - public void commit(String runId, RunPayload checkpoint, List entries) { - throw unavailable; - } - }; - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunPayload before = journal.snapshot().checkpoint(); + assertThat(modelCalls.get()).isZero(); + assertThat(toolCalls.get()).isZero(); + assertThat(snapshot).isSameAs(before); + assertThat(store.find(run.namespace(), RUN_ID)).contains(before); + } + + @Test + void responseCommitFailurePreventsToolCallsAndRetainsTheRequestCheckpoint() { + IllegalStateException unavailable = new IllegalStateException("response storage unavailable"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, false, unavailable); + RunSnapshot before = snapshot; AtomicInteger modelCalls = new AtomicInteger(); AtomicInteger toolCalls = new AtomicInteger(); - assertThatThrownBy(() -> executeReference(failing, turn, request(FIRST_TURN), () -> { + assertThatThrownBy(() -> executeReference(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), + request(FIRST_TURN), () -> { + modelCalls.incrementAndGet(); + return toolCallingResponse("call-1"); + }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); + + assertThat(modelCalls.get()).isEqualTo(1); + assertThat(toolCalls.get()).isZero(); + assertThat(snapshot.revision()).isEqualTo(before.revision() + 1); + assertThat(snapshot.checkpoint()).isEqualTo(before.checkpoint()); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req"); + assertThat(store.find(run.namespace(), RUN_ID)).contains(snapshot); + } + + @Test + void lostResponseCommitConfirmationPreventsDispatchUntilReloadAndReplay() { + IllegalStateException lostConfirmation = new IllegalStateException("commit confirmation lost"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, true, lostConfirmation); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); + Supplier model = () -> { modelCalls.incrementAndGet(); return toolCallingResponse("call-1"); - }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); + }; + + assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), model, + call -> toolCalls.incrementAndGet())).isSameAs(lostConfirmation); + + assertThat(modelCalls.get()).isEqualTo(1); + assertThat(toolCalls.get()).isZero(); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req"); + RunSnapshot committed = store.find(run.namespace(), RUN_ID).orElseThrow(); + assertThat(committed.revision()).isEqualTo(snapshot.revision() + 1); + assertThat(committed.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); + + // Reload instead of assuming that a thrown commit exception means no write occurred. + snapshot = committed; + recorder = new ModelTurnRecorder(); + executeReference(turn, request(FIRST_TURN), model, call -> toolCalls.incrementAndGet()); assertThat(modelCalls.get()).isEqualTo(1); + assertThat(toolCalls.get()).isEqualTo(1); + assertThat(snapshot).isSameAs(committed); + } + + @Test + void staleSnapshotRequestCommitConflictsBeforeCallingTheModel() { + RunSnapshot stale = snapshot; + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + commitRequest(turn, request(FIRST_TURN)); + commitResponse(turn, toolCallingResponse("call-1"), TOOLS); + RunSnapshot committed = snapshot; + snapshot = stale; + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); + + assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), () -> { + modelCalls.incrementAndGet(); + return toolCallingResponse("call-2"); + }, call -> toolCalls.incrementAndGet())).isInstanceOf(RunStoreConflictException.class); + + assertThat(modelCalls.get()).isZero(); assertThat(toolCalls.get()).isZero(); - assertThat(journal.snapshot().checkpoint()).isEqualTo(before); - assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#req"); - assertThat(store.find(run.namespace(), RUN_ID)).contains(journal.snapshot()); + assertThat(snapshot).isSameAs(stale); + assertThat(store.find(run.namespace(), RUN_ID)).contains(committed); } @Test - void replaysCommittedPlanAfterWriterAndRecorderAreRecreatedWithoutCallingTheModel() { + void replaysCommittedPlanAfterRecorderAndSnapshotAreReloadedWithoutCallingTheModel() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "planner/visit-3/model-2"); - recorder.recordRequest(turn, request(FIRST_TURN)); + commitRequest(turn, request(FIRST_TURN)); commitResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); + RunEntry planned = find(turn.turnId() + "#task.0").orElseThrow(); + long revision = snapshot.revision(); // Recreate the execution objects after plan persistence, before tool dispatch. - journal = new RunStoreJournalWriter(store, lease, store.find(run.namespace(), RUN_ID).orElseThrow()); + snapshot = store.find(run.namespace(), RUN_ID).orElseThrow(); + recorder = new ModelTurnRecorder(); AtomicInteger modelCalls = new AtomicInteger(); AtomicInteger toolCalls = new AtomicInteger(); - ChatResponse replayed = executeReference(journal, + ChatResponse replayed = executeReference( new ModelTurnRecorder.Turn(RUN_ID, "planner/visit-3/model-2"), request(FIRST_TURN), () -> { modelCalls.incrementAndGet(); return toolCallingResponse("provider-call-2"); @@ -262,8 +336,10 @@ void replaysCommittedPlanAfterWriterAndRecorderAreRecreatedWithoutCallingTheMode assertThat(modelCalls.get()).isZero(); assertThat(toolCalls.get()).isEqualTo(1); - assertThat(journal.snapshot().checkpoint()).isEqualTo(checkpoint(turn)); - assertThat(journal.snapshot().journal()).containsOnlyKeys("planner/visit-3/model-2#req", + assertThat(snapshot.revision()).isEqualTo(revision); + assertThat(find(turn.turnId() + "#task.0")).contains(planned); + assertThat(snapshot.checkpoint()).isEqualTo(checkpoint(turn)); + assertThat(snapshot.journal()).containsOnlyKeys("planner/visit-3/model-2#req", "planner/visit-3/model-2#resp", "planner/visit-3/model-2#task.0"); assertThat(replayed.toolCalls()) .containsExactly(new ToolCall("provider-call-1", "search", "{\"q\":\"argi\"}")); @@ -271,13 +347,13 @@ void replaysCommittedPlanAfterWriterAndRecorderAreRecreatedWithoutCallingTheMode @Test void rejectsReplayWhenTheRecordedRequestDiffers() { - recorder.recordRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); + commitRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); commitResponse(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), toolCallingResponse("provider-call-1"), TOOLS); ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); List different = List.of(ChatMessage.user("a different instruction")); - assertThatThrownBy(() -> recorder.replay(turn, request(different))) + assertThatThrownBy(() -> recorder.replay(turn, request(different), snapshot)) .isInstanceOf(ModelTurnJournalException.class) .hasMessageContaining("different request") .hasCauseInstanceOf(RunStoreConflictException.class); @@ -285,40 +361,198 @@ void rejectsReplayWhenTheRecordedRequestDiffers() { @Test void anInterruptedTurnFailsClosedInsteadOfAskingAgain() { - recorder.recordRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); + commitRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN))) + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), snapshot)) .isInstanceOf(ModelTurnJournalException.class) .hasMessageContaining("never recorded"); - assertThat(journal.find(RUN_ID, "agent:0#resp")).isEmpty(); + assertThat(find("agent:0#resp")).isEmpty(); } @Test - void ajournalFailureSurfacesAsAJournalFailure() { - RunJournalWriter failing = new RunJournalWriter() { - @Override - public Optional find(String streamId, String entryId) { - return Optional.empty(); - } + void rejectsBlankTurnIdentityComponents() { + for (String invalid : new String[] { null, "", " \t" }) { + assertThatThrownBy(() -> new ModelTurnRecorder.Turn(invalid, "turn")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ModelTurnRecorder.Turn(RUN_ID, invalid)) + .isInstanceOf(IllegalArgumentException.class); + } + } - @Override - public void commit(String runId, RunPayload checkpoint, List entries) { - throw new IllegalStateException("journal unavailable"); - } + @Test + void replayRejectsASnapshotFromAnotherRun() { + assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn("another-run", "agent:0"), + request(FIRST_TURN), snapshot)).isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("Run snapshot does not match"); + } + + @Test + void responseWithoutRequestFailsBeforeModelOrToolCalls() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + commitResponse(turn, toolCallingResponse("call-1"), TOOLS); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); + + assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), () -> { + modelCalls.incrementAndGet(); + return toolCallingResponse("call-2"); + }, call -> toolCalls.incrementAndGet())).isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("missing its request fingerprint"); + + assertThat(modelCalls.get()).isZero(); + assertThat(toolCalls.get()).isZero(); + } + + @Test + void replayRejectsAnOrphanPlanInsteadOfTreatingTheTurnAsNew() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunEntry plan = recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS).get(1); + RunSnapshot orphaned = snapshotWithJournal(List.of(plan)); + + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), orphaned)) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("missing its request fingerprint"); + } - @Override - public void append(String streamId, List entries) { - throw new IllegalStateException("journal unavailable"); + @Test + void replayRejectsIncompatibleEntryKindsTypesAndPayloadVersions() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + List complete = completeEntries(turn); + for (int index = 0; index < complete.size(); index++) { + RunEntry original = complete.get(index); + List incompatible = List.of( + new RunEntry(1, original.id(), RunEntry.Kind.EVENT, original.payload()), + new RunEntry(1, original.id(), original.kind(), new RunPayload(1, "other.type", "{}")), + new RunEntry(1, original.id(), original.kind(), + new RunPayload(2, original.payload().type(), original.payload().json()))); + for (RunEntry replacement : incompatible) { + List entries = new ArrayList<>(complete); + entries.set(index, replacement); + RunSnapshot invalid = snapshotWithJournal(entries); + + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), invalid)) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("Incompatible journal entry " + original.id()); } - }; - ModelTurnRecorder recorder = ModelTurnRecorder.of(failing); + } + } + + @Test + void replayRejectsAnEntryWhoseIdDoesNotMatchItsJournalKey() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunSnapshot complete = snapshotWithJournal(completeEntries(turn)); + Map entries = new HashMap<>(complete.journal()); + RunEntry request = entries.get("agent:0#req"); + entries.put(request.id(), new RunEntry(1, "another-turn#req", request.kind(), request.payload())); + RunSnapshot invalid = new RunSnapshot(complete.spec(), complete.revision(), complete.state(), + complete.checkpoint(), complete.tasks(), entries, complete.outbox()); + + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), invalid)) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("Incompatible journal entry agent:0#req"); + } - assertThatThrownBy(() -> recorder.recordRequest(turn, request(FIRST_TURN))) + @Test + void replayRejectsARequestRecordWithoutAFingerprint() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunEntry invalid = new RunEntry(1, "agent:0#req", RunEntry.Kind.MODEL_CALL, + new RunPayload(1, ModelTurnCodec.REQUEST_TYPE, "{}")); + RunSnapshot recorded = snapshotWithJournal(List.of(invalid)); + + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), recorded)) .isInstanceOf(ModelTurnJournalException.class) - .hasRootCauseMessage("journal unavailable"); + .hasMessageContaining("invalid request fingerprint"); + } + + @Test + void replayRejectsMissingExtraAndNonContiguousPlans() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + List complete = completeEntries(turn); + RunEntry plan = complete.get(2); + RunEntry extra = new RunEntry(1, "agent:0#task.1", plan.kind(), plan.payload()); + RunEntry alias = new RunEntry(1, "agent:0#task.00", plan.kind(), plan.payload()); + List> incomplete = List.of(complete.subList(0, 2), + List.of(complete.get(0), complete.get(1), extra), + List.of(complete.get(0), complete.get(1), plan, extra), + List.of(complete.get(0), complete.get(1), plan, alias)); + for (List entries : incomplete) { + RunSnapshot recorded = snapshotWithJournal(entries); + + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), recorded)) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("tool plan"); + } + } + + @Test + void replayRejectsPlanContentsThatDisagreeWithTheResponseOrDisclosedTools() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + List complete = completeEntries(turn); + RunEntry plan = complete.get(2); + String json = plan.payload().json(); + List changed = List.of(json.replace("\"ordinal\":0", "\"ordinal\":1"), + json.replace("\"toolName\":\"search\"", "\"toolName\":\"another-tool\""), + json.replace("\"argumentsHash\":\"", "\"argumentsHash\":\"changed-"), + json.replace("\"definitionVersion\":\"", "\"definitionVersion\":\"changed-"), + ModelTurnCodec.encodeToolPlan(new ToolCall("call-1", "search", "{\"q\":\"different\"}"), + TOOLS.get(0), 0)); + for (String changedJson : changed) { + RunEntry replacement = new RunEntry(1, plan.id(), plan.kind(), + new RunPayload(1, ModelTurnCodec.PLAN_TYPE, changedJson)); + RunSnapshot invalid = snapshotWithJournal(List.of(complete.get(0), complete.get(1), replacement)); + + assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), invalid)) + .isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("tool plan does not match"); + } + } + + @Test + void replayScopesPlansToExactTurnIdsIncludingSeparatorLikeCharacters() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent.[0]+"); + ModelTurnRecorder.Turn other = new ModelTurnRecorder.Turn(RUN_ID, turn.turnId() + "#task.0"); + List entries = new ArrayList<>(completeEntries(turn)); + entries.addAll(completeEntries(other)); + RunSnapshot recorded = snapshotWithJournal(entries); + + assertThat(recorder.replay(turn, request(FIRST_TURN), recorded)).contains(toolCallingResponse("call-1")); + assertThat(recorder.replay(other, request(FIRST_TURN), recorded)).contains(toolCallingResponse("call-1")); + } + + @Test + void textOnlyResponseReplaysWithNoToolPlans() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + ChatResponse text = new ChatResponse("response", "model", List.of(ChatMessage.assistant("done")), + List.of(), FinishReason.STOP, null, Map.of()); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger toolCalls = new AtomicInteger(); + Supplier model = () -> { + modelCalls.incrementAndGet(); + return text; + }; + + assertThat(executeReference(turn, request(FIRST_TURN), model, call -> toolCalls.incrementAndGet())) + .isEqualTo(text); + assertThat(executeReference(turn, request(FIRST_TURN), model, call -> toolCalls.incrementAndGet())) + .isEqualTo(text); + + assertThat(modelCalls.get()).isEqualTo(1); + assertThat(toolCalls.get()).isZero(); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp"); + } + + @Test + void replayValidatesCurrentContentsWithoutInterpretingTheCheckpointOrCommitHistory() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + commitRequest(turn, request(FIRST_TURN)); + List entries = recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS); + // Deliberately violate producer ordering to show that replay cannot prove atomic history. + commit(null, entries.subList(0, 1)); + commit(new RunPayload(1, "application.checkpoint", "{\"opaque\":true}"), entries.subList(1, entries.size())); + + assertThat(recorder.replay(turn, request(FIRST_TURN), snapshot)).contains(toolCallingResponse("call-1")); } @Test @@ -329,26 +563,27 @@ void providerCallIdsDoNotChangeThePlannedTaskIdentity() { assertThat(renamed.stream().filter(entry -> entry.kind() == RunEntry.Kind.TASK_CALL).toList()) .isEqualTo(first.stream().filter(entry -> entry.kind() == RunEntry.Kind.TASK_CALL).toList()); - recorder.recordRequest(turn, request(FIRST_TURN)); - journal.commit(RUN_ID, checkpoint(turn), first); - assertThat(recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN))) + commitRequest(turn, request(FIRST_TURN)); + commit(checkpoint(turn), first); + assertThat(recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN), snapshot)) .contains(toolCallingResponse("provider-call-1")); - assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); } @Test void theSameTaskIdentityWithDifferentArgumentsConflictsWithoutUpdatingCheckpoint() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + commitRequest(turn, request(FIRST_TURN)); commitResponse(turn, toolCallingResponse("call-1"), TOOLS); - RunSnapshot before = journal.snapshot(); + RunSnapshot before = snapshot; RunEntry changed = new RunEntry(1, "agent:0#task.0", RunEntry.Kind.TASK_CALL, new RunPayload(1, ModelTurnCodec.PLAN_TYPE, ModelTurnCodec.encodeToolPlan( new ToolCall("call-2", "search", "{\"q\":\"different\"}"), TOOLS.get(0), 0))); - assertThatThrownBy(() -> journal.commit(RUN_ID, new RunPayload(1, "test.checkpoint", "{\"changed\":true}"), + assertThatThrownBy(() -> commit(new RunPayload(1, "test.checkpoint", "{\"changed\":true}"), List.of(changed))).isInstanceOf(RunStoreConflictException.class); - assertThat(journal.snapshot()).isSameAs(before); + assertThat(snapshot).isSameAs(before); assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } @@ -356,33 +591,92 @@ void theSameTaskIdentityWithDifferentArgumentsConflictsWithoutUpdatingCheckpoint void streamedTextIsRecordedUnderOneStableAttempt() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); String attempt = ModelTurnRecorder.newAttempt(); - RunSnapshot before = journal.snapshot(); + RunSnapshot before = snapshot; - recorder.recordDelta(turn, attempt, 0, "he"); - recorder.recordDelta(turn, attempt, 1, "llo"); - recorder.recordDelta(turn, attempt, 2, ""); + commit(null, recorder.prepareDelta(turn, attempt, 0, "he")); + commit(null, recorder.prepareDelta(turn, attempt, 1, "llo")); + assertThat(recorder.prepareDelta(turn, attempt, 2, "")).isEmpty(); - assertThat(journal.snapshot().journal()).containsOnlyKeys("agent:0#progress." + attempt + "#delta.0", + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#progress." + attempt + "#delta.0", "agent:0#progress." + attempt + "#delta.1"); - assertThat(journal.snapshot().revision()).isEqualTo(before.revision() + 2); - assertThat(journal.snapshot().checkpoint()).isEqualTo(before.checkpoint()); + assertThat(snapshot.revision()).isEqualTo(before.revision() + 2); + assertThat(snapshot.checkpoint()).isEqualTo(before.checkpoint()); // Progress is diagnostic only: it never produces a response or a plan. - assertThat(journal.find(RUN_ID, "agent:0#resp")).isEmpty(); - assertThat(journal.find(RUN_ID, "agent:0#task.0")).isEmpty(); + assertThat(find("agent:0#resp")).isEmpty(); + assertThat(find("agent:0#task.0")).isEmpty(); + assertThat(recorder.replay(turn, request(FIRST_TURN), snapshot)).isEmpty(); + } + + @Test + void preparingDeltasIsImmutableAndHasNoStorageSideEffects() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunSnapshot before = snapshot; + String attempt = ModelTurnRecorder.newAttempt(); + + List entries = recorder.prepareDelta(turn, attempt, 0, "hello"); + + assertThat(entries).extracting(RunEntry::id).containsExactly("agent:0#progress." + attempt + "#delta.0"); + assertThatThrownBy(entries::clear).isInstanceOf(UnsupportedOperationException.class); + assertThat(recorder.prepareDelta(turn, attempt, 1, "")).isEmpty(); + assertThat(recorder.prepareDelta(turn, attempt, 1, null)).isEmpty(); + assertThat(snapshot).isSameAs(before); + assertThat(store.find(run.namespace(), RUN_ID)).contains(before); + } + + @Test + void referenceCommitsDeltasBeforeTheResponseUsingEachSuccessfulRevision() { + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + RunSnapshot before = snapshot; + String attempt = ModelTurnRecorder.newAttempt(); + AtomicInteger toolCalls = new AtomicInteger(); + + executeReference(turn, request(FIRST_TURN), () -> { + commit(null, recorder.prepareDelta(turn, attempt, 0, "he")); + commit(null, recorder.prepareDelta(turn, attempt, 1, "llo")); + assertThat(snapshot.revision()).isEqualTo(before.revision() + 3); + assertThat(snapshot.checkpoint()).isEqualTo(before.checkpoint()); + assertThat(find("agent:0#resp")).isEmpty(); + return toolCallingResponse("call-1"); + }, call -> { + assertThat(snapshot.checkpoint()).isEqualTo(checkpoint(turn)); + assertThat(snapshot.revision()).isEqualTo(before.revision() + 4); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0", + "agent:0#progress." + attempt + "#delta.0", "agent:0#progress." + attempt + "#delta.1"); + toolCalls.incrementAndGet(); + }); + + assertThat(toolCalls.get()).isEqualTo(1); + } + + @Test + void deltaCommitFailureIsPropagatedBeforeTheResponseAndToolDispatch() { + IllegalStateException unavailable = new IllegalStateException("delta storage unavailable"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.DELTA_TYPE, false, unavailable); + ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + AtomicInteger toolCalls = new AtomicInteger(); + + assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), () -> { + commit(null, recorder.prepareDelta(turn, ModelTurnRecorder.newAttempt(), 0, "hello")); + return toolCallingResponse("call-1"); + }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); + + assertThat(toolCalls.get()).isZero(); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req"); + assertThat(store.find(run.namespace(), RUN_ID)).contains(snapshot); } @Test void differentCallsWithTheSameRequestHaveIndependentRecords() { ModelTurnRecorder.Turn first = new ModelTurnRecorder.Turn(RUN_ID, "first-call"); ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(RUN_ID, "next-call"); - recorder.recordRequest(first, request(FIRST_TURN)); + commitRequest(first, request(FIRST_TURN)); commitResponse(first, toolCallingResponse("call-1"), TOOLS); - assertThat(recorder.replay(next, request(FIRST_TURN))).isEmpty(); - recorder.recordRequest(next, request(FIRST_TURN)); + assertThat(recorder.replay(next, request(FIRST_TURN), snapshot)).isEmpty(); + commitRequest(next, request(FIRST_TURN)); commitResponse(next, toolCallingResponse("call-2"), TOOLS); - assertThat(journal.snapshot().journal()).containsOnlyKeys("first-call#req", "first-call#resp", "first-call#task.0", + assertThat(snapshot.journal()).containsOnlyKeys("first-call#req", "first-call#resp", "first-call#task.0", "next-call#req", "next-call#resp", "next-call#task.0"); } @@ -395,7 +689,7 @@ void differentCallsWithTheSameRequestHaveIndependentRecords() { void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { for (long ordinal = 0; ordinal < 3; ordinal++) { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:" + ordinal); - recorder.recordRequest(turn, request(List.of(ChatMessage.user("question " + ordinal)))); + commitRequest(turn, request(List.of(ChatMessage.user("question " + ordinal)))); commitResponse(turn, toolCallingResponse("call-" + ordinal), TOOLS); } @@ -406,9 +700,9 @@ void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { ChatMessage.user("question 3")); ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(RUN_ID, "after-summary"); - assertThat(recorder.replay(next, request(summarisedHistory))).isEmpty(); - recorder.recordRequest(next, request(summarisedHistory)); - assertThat(journal.find(RUN_ID, "after-summary#req")).isPresent(); + assertThat(recorder.replay(next, request(summarisedHistory), snapshot)).isEmpty(); + commitRequest(next, request(summarisedHistory)); + assertThat(find("after-summary#req")).isPresent(); } /** @@ -420,7 +714,7 @@ void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { @Test void aNonDeterministicRewriteOfTheSameTurnFailsClosed() { ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "summary-turn"); - recorder.recordRequest(turn, request(List.of(ChatMessage.system("summary, first attempt"), + commitRequest(turn, request(List.of(ChatMessage.system("summary, first attempt"), ChatMessage.user("question")))); commitResponse(turn, toolCallingResponse("call-1"), TOOLS); @@ -428,18 +722,14 @@ void aNonDeterministicRewriteOfTheSameTurnFailsClosed() { List rebuilt = List.of(ChatMessage.system("summary, second attempt"), ChatMessage.user("question")); - assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "summary-turn"), request(rebuilt))) + assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "summary-turn"), + request(rebuilt), snapshot)) .isInstanceOf(ModelTurnJournalException.class) .hasMessageContaining("summarisation"); } - @Test - void aDisabledJournalYieldsNoRecorder() { - assertThat(ModelTurnRecorder.of(null)).isNull(); - } - private void commitResponse(ModelTurnRecorder.Turn turn, ChatResponse response, Iterable tools) { - journal.commit(RUN_ID, checkpoint(turn), recorder.prepareResponse(turn, response, tools)); + commit(checkpoint(turn), recorder.prepareResponse(turn, response, tools)); } private static RunPayload checkpoint(ModelTurnRecorder.Turn turn) { @@ -447,21 +737,45 @@ private static RunPayload checkpoint(ModelTurnRecorder.Turn turn) { "{\"phase\":\"TOOLS_PENDING\",\"turnId\":\"" + turn.turnId() + "\"}"); } - /** Reference ordering only; tool receipts and production engine recovery are separate. */ - private static ChatResponse executeReference(RunJournalWriter writer, ModelTurnRecorder.Turn turn, - ChatRequest request, Supplier model, Consumer dispatch) { - ModelTurnRecorder recorder = ModelTurnRecorder.of(writer); - ChatResponse response = recorder.replay(turn, request).orElseGet(() -> { - recorder.recordRequest(turn, request); + /** In-process recovery reference only; tool receipts and a production runtime are separate. */ + private ChatResponse executeReference(ModelTurnRecorder.Turn turn, ChatRequest request, + Supplier model, Consumer dispatch) { + ChatResponse response = recorder.replay(turn, request, snapshot).orElseGet(() -> { + commitRequest(turn, request); ChatResponse result = model.get(); - List entries = recorder.prepareResponse(turn, result, request.tools()); - writer.commit(turn.streamId(), checkpoint(turn), entries); + commitResponse(turn, result, request.tools()); return result; }); response.toolCalls().forEach(dispatch); return response; } + private void commitRequest(ModelTurnRecorder.Turn turn, ChatRequest request) { + commit(null, recorder.prepareRequest(turn, request)); + } + + private void commit(RunPayload checkpoint, List entries) { + snapshot = runtimeStore.commit(run.namespace(), RUN_ID, lease, + new RunCommit(snapshot.revision(), snapshot.state(), checkpoint, List.of(), entries, List.of())); + } + + private Optional find(String id) { + return Optional.ofNullable(snapshot.journal().get(id)); + } + + private List completeEntries(ModelTurnRecorder.Turn turn) { + List entries = new ArrayList<>(recorder.prepareRequest(turn, request(FIRST_TURN))); + entries.addAll(recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS)); + return entries; + } + + private RunSnapshot snapshotWithJournal(List entries) { + Map journal = new HashMap<>(); + entries.forEach(entry -> journal.put(entry.id(), entry)); + return new RunSnapshot(snapshot.spec(), snapshot.revision(), snapshot.state(), snapshot.checkpoint(), + snapshot.tasks(), journal, snapshot.outbox()); + } + private static ChatRequest request(List messages) { return new ChatRequest(messages, TOOLS, null, Map.of()); } @@ -472,4 +786,71 @@ private static ChatResponse toolCallingResponse(String callId) { List.of(call), FinishReason.TOOL_CALLS, null, Map.of()); } + /** Simulates both definite rejection and a write whose acknowledgement is lost. */ + private static final class FailingRunStore implements RunStore { + + private final RunStore delegate; + + private final String payloadType; + + private final boolean commitBeforeFailure; + + private final RuntimeException failure; + + private FailingRunStore(RunStore delegate, String payloadType, boolean commitBeforeFailure, + RuntimeException failure) { + this.delegate = delegate; + this.payloadType = payloadType; + this.commitBeforeFailure = commitBeforeFailure; + this.failure = failure; + } + + @Override + public RunStoreCapabilities capabilities() { + return delegate.capabilities(); + } + + @Override + public RunSnapshot submit(RunSpec spec, TrustedExecutionContext context) { + return delegate.submit(spec, context); + } + + @Override + public Optional find(RunNamespace namespace, String runId) { + return delegate.find(namespace, runId); + } + + @Override + public Optional activeRun(RunNamespace namespace) { + return delegate.activeRun(namespace); + } + + @Override + public RunLease acquire(RunNamespace namespace, String runId, UUID ownerId, Duration ttl) { + return delegate.acquire(namespace, runId, ownerId, ttl); + } + + @Override + public RunLease renew(RunLease lease, Duration ttl) { + return delegate.renew(lease, ttl); + } + + @Override + public boolean release(RunLease lease) { + return delegate.release(lease); + } + + @Override + public RunSnapshot commit(RunNamespace namespace, String runId, RunLease lease, RunCommit commit) { + if (commit.journal().stream().anyMatch(entry -> payloadType.equals(entry.payload().type()))) { + if (commitBeforeFailure) { + delegate.commit(namespace, runId, lease, commit); + } + throw failure; + } + return delegate.commit(namespace, runId, lease, commit); + } + + } + } diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java deleted file mode 100644 index 39cd8d6a4..000000000 --- a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunJournalWriter.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2025-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.runtime.execution.run; - -import java.util.List; -import java.util.Optional; - -/** - * Narrow journal access supplied by a runtime that owns the run's submission, lease, - * revision and execution position. Writes use {@link RunStore#commit}, rather than an - * independent journal backend. Engines need not manage storage ownership themselves. - * - *

    - * Entry ids are stable within a run. Identical journal data may be submitted again; - * changed data under the same id is a conflict. Every successful write may advance the - * run revision, including a write containing only previously recorded entries. - */ -public interface RunJournalWriter { - - /** Reads an entry from the runtime's committed view of the run. */ - Optional find(String runId, String entryId); - - /** - * Atomically appends journal entries through a run commit, retaining the current - * checkpoint. Used for request fingerprints and diagnostic progress. - * @throws RunStoreConflictException when the run commit or an entry conflicts - */ - void append(String runId, List entries); - - /** - * Atomically commits the supplied checkpoint and every journal entry. The runtime - * calls this after preparing the corresponding execution position; returning means - * the storage commit has completed, not that the entries have merely been buffered. - * @param runId the run whose checkpoint and journal advance together - * @param checkpoint the non-null checkpoint corresponding to these entries - * @param entries the complete batch to commit - * @throws RunStoreConflictException when the run commit or an entry conflicts - */ - void commit(String runId, RunPayload checkpoint, List entries); - -} diff --git a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java b/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java deleted file mode 100644 index c188fd649..000000000 --- a/argi-runtime/argi-runtime-core/src/main/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriter.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2025-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.runtime.execution.run; - -import java.util.List; -import java.util.Optional; - -import static java.util.Objects.requireNonNull; - -/** - * Reference journal writer bound to a runtime-supplied lease and committed run snapshot. - * The store remains the only persistence backend and supplies all atomicity, fencing and - * revision checks. This adapter neither admits runs nor manages leases or transitions. - * - *

    - * All writes are serialized and use the last successful snapshot's revision. A failed - * commit leaves that view unchanged and is propagated without retry. If the runtime - * advances the run through another path, it must create a new writer from the updated - * snapshot. Reads use the bound view rather than silently loading a newer revision. - */ -public final class RunStoreJournalWriter implements RunJournalWriter { - - private final RunStore store; - - private final RunLease lease; - - private RunSnapshot snapshot; - - public RunStoreJournalWriter(RunStore store, RunLease lease, RunSnapshot snapshot) { - this.store = requireNonNull(store, "store cannot be null"); - this.lease = requireNonNull(lease, "lease cannot be null"); - this.snapshot = requireNonNull(snapshot, "snapshot cannot be null"); - if (!lease.namespace().equals(snapshot.spec().namespace())) { - throw new IllegalArgumentException("lease namespace does not match the run"); - } - } - - @Override - public synchronized Optional find(String runId, String entryId) { - requireRun(runId); - RunValidation.text(entryId, "entryId"); - return Optional.ofNullable(snapshot.journal().get(entryId)); - } - - @Override - public synchronized void append(String runId, List entries) { - write(runId, null, entries); - } - - @Override - public synchronized void commit(String runId, RunPayload checkpoint, List entries) { - write(runId, requireNonNull(checkpoint, "checkpoint cannot be null"), entries); - } - - /** The last successful committed view, for the runtime coordinating this writer. */ - public synchronized RunSnapshot snapshot() { - return snapshot; - } - - private void write(String runId, RunPayload checkpoint, List entries) { - requireRun(runId); - RunCommit commit = new RunCommit(snapshot.revision(), snapshot.state(), checkpoint, List.of(), entries, - List.of()); - snapshot = store.commit(snapshot.spec().namespace(), runId, lease, commit); - } - - private void requireRun(String runId) { - if (!snapshot.spec().runId().equals(runId)) { - throw new IllegalArgumentException("runId does not match the bound run"); - } - } - -} diff --git a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java b/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java deleted file mode 100644 index 3c6abbec6..000000000 --- a/argi-runtime/argi-runtime-core/src/test/java/io/github/agentic/ai/runtime/execution/run/RunStoreJournalWriterTest.java +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright 2025-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.runtime.execution.run; - -import io.github.agentic.ai.runtime.execution.PrincipalReference; -import io.github.agentic.ai.runtime.execution.TenantReference; -import io.github.agentic.ai.runtime.execution.TrustedExecutionContext; -import io.github.agentic.ai.runtime.execution.definition.DefinitionKind; -import io.github.agentic.ai.runtime.execution.definition.DefinitionReference; - -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.Callable; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -/** Adapter behavior; backend atomicity and fencing remain in RunStoreContractTest. */ -@Timeout(10) -class RunStoreJournalWriterTest { - - private static final String RUN_ID = "run-1"; - - private final RunStore store = new MemoryRunStore(); - - private RunSpec run; - - private RunLease lease; - - private RunStoreJournalWriter writer; - - @BeforeEach - void setUp() { - run = spec(RUN_ID, DefinitionKind.AGENT); - store.submit(run, context(RUN_ID)); - lease = store.acquire(run.namespace(), RUN_ID, UUID.randomUUID(), Duration.ofMinutes(1)); - RunSnapshot running = store.commit(run.namespace(), RUN_ID, lease, - new RunCommit(0, RunState.RUNNING, checkpoint("MODEL_READY"), List.of(), List.of(), - List.of(entry("started", RunEntry.Kind.EVENT, "{}")))); - writer = new RunStoreJournalWriter(store, lease, running); - } - - @Test - void appendRetainsCheckpointStateAndOtherRunData() { - RunSnapshot before = writer.snapshot(); - RunEntry request = entry("turn#req", RunEntry.Kind.MODEL_CALL, "{\"hash\":\"request\"}"); - RunEntry delta = entry("turn#delta.0", RunEntry.Kind.EVENT, "{\"text\":\"hello\"}"); - - writer.append(RUN_ID, List.of(request)); - writer.append(RUN_ID, List.of(delta)); - - RunSnapshot after = writer.snapshot(); - assertThat(after.revision()).isEqualTo(before.revision() + 2); - assertThat(after.checkpoint()).isEqualTo(before.checkpoint()); - assertThat(after.state()).isEqualTo(before.state()); - assertThat(after.tasks()).isEqualTo(before.tasks()); - assertThat(after.outbox()).isEqualTo(before.outbox()); - assertThat(after.journal()).containsOnlyKeys(request.id(), delta.id()); - assertThat(writer.find(RUN_ID, request.id())).contains(request); - assertThat(writer.find(RUN_ID, "missing")).isEmpty(); - assertThat(store.find(run.namespace(), RUN_ID)).contains(after); - } - - @Test - void commitsCheckpointAndWholePlanInOneRevision() { - RunSnapshot before = writer.snapshot(); - RunPayload checkpoint = checkpoint("TOOLS_PENDING"); - List entries = List.of(entry("turn#resp", RunEntry.Kind.MODEL_CALL, "{}"), - entry("turn#task.0", RunEntry.Kind.TASK_CALL, "{\"ordinal\":0}"), - entry("turn#task.1", RunEntry.Kind.TASK_CALL, "{\"ordinal\":1}")); - - writer.commit(RUN_ID, checkpoint, entries); - - RunSnapshot after = store.find(run.namespace(), RUN_ID).orElseThrow(); - assertThat(after).isEqualTo(writer.snapshot()); - assertThat(after.revision()).isEqualTo(before.revision() + 1); - assertThat(after.checkpoint()).isEqualTo(checkpoint); - assertThat(after.journal().values()).containsExactlyInAnyOrderElementsOf(entries); - assertThat(after.state()).isEqualTo(before.state()); - assertThat(after.tasks()).isEqualTo(before.tasks()); - assertThat(after.outbox()).isEqualTo(before.outbox()); - } - - @Test - void conflictingBatchLeavesCheckpointAndCommittedViewUnchanged() { - writer.append(RUN_ID, List.of(entry("turn#task.0", RunEntry.Kind.TASK_CALL, "{\"input\":1}"))); - RunSnapshot before = writer.snapshot(); - - assertThatThrownBy(() -> writer.commit(RUN_ID, checkpoint("TOOLS_PENDING"), - List.of(entry("turn#resp", RunEntry.Kind.MODEL_CALL, "{}"), - entry("turn#task.0", RunEntry.Kind.TASK_CALL, "{\"input\":2}")))) - .isInstanceOf(RunStoreConflictException.class); - - assertThat(writer.snapshot()).isSameAs(before); - assertThat(store.find(run.namespace(), RUN_ID)).contains(before); - assertThat(writer.find(RUN_ID, "turn#resp")).isEmpty(); - } - - @Test - void externalProgressRequiresExplicitRebindingInsteadOfRetryingWithANewerRevision() { - RunSnapshot before = writer.snapshot(); - RunEntry external = entry("external", RunEntry.Kind.EVENT, "{}"); - RunSnapshot progressed = store.commit(run.namespace(), RUN_ID, lease, - new RunCommit(before.revision(), RunState.RUNNING, checkpoint("TOOLS_COMPLETED"), List.of(), - List.of(external), List.of())); - - assertThatThrownBy(() -> writer.commit(RUN_ID, checkpoint("TOOLS_PENDING"), - List.of(entry("turn#resp", RunEntry.Kind.MODEL_CALL, "{}")))) - .isInstanceOf(RunStoreConflictException.class); - assertThat(writer.snapshot()).isSameAs(before); - assertThat(writer.find(RUN_ID, external.id())).isEmpty(); - assertThat(store.find(run.namespace(), RUN_ID)).contains(progressed); - - RunStoreJournalWriter rebound = new RunStoreJournalWriter(store, lease, progressed); - assertThat(rebound.find(RUN_ID, external.id())).contains(external); - rebound.append(RUN_ID, List.of(entry("next#req", RunEntry.Kind.MODEL_CALL, "{}"))); - assertThat(rebound.snapshot().revision()).isEqualTo(progressed.revision() + 1); - assertThat(rebound.snapshot().checkpoint()).isEqualTo(progressed.checkpoint()); - } - - @Test - void serializesConcurrentAppendsUsingEachSuccessfulRevision() throws Exception { - long revision = writer.snapshot().revision(); - var executor = Executors.newFixedThreadPool(4); - try { - List> writes = new ArrayList<>(); - for (int i = 0; i < 12; i++) { - String id = "delta-" + i; - writes.add(() -> { - writer.append(RUN_ID, List.of(entry(id, RunEntry.Kind.EVENT, "{}"))); - return null; - }); - } - for (var result : executor.invokeAll(writes)) { - result.get(5, TimeUnit.SECONDS); - } - } - finally { - executor.shutdownNow(); - } - assertThat(writer.snapshot().revision()).isEqualTo(revision + 12); - assertThat(writer.snapshot().journal()).hasSize(12); - assertThat(store.find(run.namespace(), RUN_ID)).contains(writer.snapshot()); - } - - @Test - void acceptsBothGraphAndIndependentAgentRunDefinitions() { - for (DefinitionKind kind : List.of(DefinitionKind.GRAPH, DefinitionKind.AGENT)) { - String runId = kind.wireName() + "-run"; - RunSpec spec = spec(runId, kind); - RunSnapshot admitted = store.submit(spec, context(runId)); - RunLease owned = store.acquire(spec.namespace(), runId, UUID.randomUUID(), Duration.ofMinutes(1)); - RunStoreJournalWriter bound = new RunStoreJournalWriter(store, owned, admitted); - RunEntry entry = entry("turn#req", RunEntry.Kind.MODEL_CALL, "{}"); - - bound.append(runId, List.of(entry)); - - assertThat(bound.find(runId, entry.id())).contains(entry); - assertThat(bound.snapshot().spec().definition().kind()).isEqualTo(kind); - assertThat(store.find(spec.namespace(), runId)).contains(bound.snapshot()); - } - } - - private static RunPayload checkpoint(String phase) { - return new RunPayload(1, "test.checkpoint", "{\"phase\":\"" + phase + "\"}"); - } - - private static RunEntry entry(String id, RunEntry.Kind kind, String json) { - return new RunEntry(1, id, kind, new RunPayload(1, "test.journal", json)); - } - - private static RunSpec spec(String runId, DefinitionKind kind) { - return RunSpec.from(context(runId), "app", runId, "submission-" + runId, - DefinitionReference.of(kind, "definition", "1"), new RunPayload(1, "test.input", "{}")); - } - - private static TrustedExecutionContext context(String runId) { - return TrustedExecutionContext.builder() - .tenant(new TenantReference("iam", "tenant")) - .principal(new PrincipalReference("iam", "user")) - .runId(runId) - .correlationId("trace") - .build(); - } - -} From 5f26327e2437b7814fee4c3862ff8aec56f81439 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Thu, 29 Oct 2026 22:27:14 +0800 Subject: [PATCH 07/12] feat(agent): support bounded retries for interrupted model turns --- .../ai/agent/durable/ModelTurnCodec.java | 23 + .../durable/ModelTurnJournalException.java | 5 +- .../ai/agent/durable/ModelTurnRecorder.java | 126 ++- .../ai/agent/durable/package-info.java | 27 +- .../agent/durable/ModelTurnRecorderTest.java | 796 ++++++------------ 5 files changed, 383 insertions(+), 594 deletions(-) diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java index 668f7916d..cbb32f5bd 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java @@ -57,6 +57,7 @@ * never journaled and adapter-level {@code attributes} are excluded from the hash; *

  • {@link ToolPlan} - the provider's tool-call id is excluded, because it correlates * protocol messages and is not a task identity;
  • + *
  • {@link AttemptRecord} - a model-call reservation pins its ordinal and request hash;
  • *
  • {@link Delta} - streamed text is a single scalar.
  • * * @@ -80,6 +81,9 @@ public final class ModelTurnCodec { /** {@code MODEL_CALL}: the request fingerprint, written before the provider call. */ public static final String REQUEST_TYPE = "argi.model-request"; + /** {@code MODEL_CALL}: a model-call reservation, committed before invoking the provider. */ + public static final String ATTEMPT_TYPE = "argi.model-attempt"; + /** {@code MODEL_CALL}: the complete terminal response. */ public static final String RESPONSE_TYPE = "argi.model-response"; @@ -139,6 +143,21 @@ public static String decodeRequestHash(String json) { return read(json, RequestRecord.class).requestHash(); } + static String encodeAttempt(int ordinal, String requestHash) { + return write(new AttemptRecord(ordinal, requestHash)); + } + + static int decodeAttemptOrdinal(String json, String requestHash) { + JsonNode record = read(json, JsonNode.class); + JsonNode ordinal = record.get("ordinal"); + JsonNode hash = record.get("requestHash"); + if (ordinal == null || !ordinal.isInt() || ordinal.intValue() < 1 || hash == null || !hash.isTextual() + || !requestHash.equals(hash.textValue())) { + throw new ModelTurnJournalException("Invalid model attempt ordinal or request fingerprint"); + } + return ordinal.intValue(); + } + /** The {@link #RESPONSE_TYPE} payload. */ public static String encodeResponse(ChatResponse response) { return write(sanitize(Objects.requireNonNull(response, "response cannot be null"))); @@ -288,6 +307,10 @@ private static String sha256(String value) { private record RequestRecord(String requestHash) { } + /** Stored shape of {@link #ATTEMPT_TYPE}; a reservation is consumed even if its result is lost. */ + private record AttemptRecord(int ordinal, String requestHash) { + } + /** * Hashed shape of a request. Excludes {@link ChatRequest#attributes()} on purpose; see * {@link #requestHash(ChatRequest)}. diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java index dc86210f4..db08f8372 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnJournalException.java @@ -17,8 +17,9 @@ /** * A model-turn payload could not be encoded or decoded, or recorded data is incomplete, - * incompatible or does not describe the run and request being replayed. This component - * performs no storage writes; runtime commits retain the original RunStore exception types. + * incompatible or does not describe the run and request being replayed, or the turn's + * model-call reservation budget is exhausted. The recorder performs no storage writes; + * runtime commits retain the original RunStore exception types. * *

    * This is not a model failure. An engine that converts caught model exceptions into diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java index 6f78a1c3a..5067f1944 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -40,8 +40,8 @@ *

    * Stateless: every preparation method returns immutable records and writes nothing. The * runtime owns the store, lease, revision and committed snapshot, and decides whether - * journaling is in use at all. It commits the request fingerprint before calling the - * provider, and the response with all its plans and the checkpoint in one + * journaling is in use at all. It commits the request fingerprint and attempt before + * calling the provider, and the response with all its plans and the checkpoint in one * {@link RunStore#commit} before dispatching a tool. Store exceptions propagate unchanged; * they are not model failures. * @@ -49,21 +49,30 @@ * Entry ids of one turn: *

      *
    • {@code #req} - the request fingerprint
    • + *
    • {@code #attempt.} - a model-call reservation, starting at 1
    • *
    • {@code #progress.#delta.} - streamed text, diagnostic only
    • *
    • {@code #resp} - the complete terminal response
    • *
    • {@code #task.} - one planned tool call
    • *
    * *

    - * An interrupted turn is never re-asked: a request without a response means its outcome is - * unknown, and budgeted re-asking belongs to the retry path. + * A matching request without a response or plan may be re-asked within its per-turn attempt + * limit. By default two calls are allowed in total, including the first. Every invocation + * requires a committed reservation from {@link #prepareAttempt(Turn, ChatRequest, RunSnapshot)}; + * restarting never resets the count. Even a reservation whose call was not issued consumes + * an attempt. Run-wide, token and cost budgets remain runtime responsibilities. */ public final class ModelTurnRecorder { + /** Total model-call reservations per turn, including the first invocation. */ + public static final int DEFAULT_MAX_ATTEMPTS = 2; + private static final int ENTRY_SCHEMA_VERSION = 1; private static final String PLAN_PREFIX = "#task."; + private static final String ATTEMPT_PREFIX = "#attempt."; + /** Creates a stateless recorder; the runtime decides whether to use journaling. */ public ModelTurnRecorder() { } @@ -103,12 +112,13 @@ public record Turn(String runId, String turnId) { * *

    * This method cannot detect snapshot staleness. After an empty result, the runtime must - * commit the request using this snapshot's revision before calling the model. A conflict + * prepare and commit an attempt using this snapshot's revision before calling the model. + * An empty result does not grant permission to call the model. A conflict * requires reloading and repeating replay, not merely retrying with a newer revision. * @param turn the engine-supplied identity of this logical call * @param request the request about to be issued * @param snapshot the runtime's consistent committed view of the run - * @return the recorded response, or empty when no request, response or plan is recorded + * @return the recorded response, or empty when the turn has no committed result or plan * @throws ModelTurnJournalException when recorded data is incomplete, incompatible or * does not match the run or request */ @@ -121,8 +131,9 @@ public Optional replay(Turn turn, ChatRequest request, RunSnapshot } RunEntry recorded = snapshot.journal().get(turn.turnId() + "#req"); RunEntry response = snapshot.journal().get(turn.turnId() + "#resp"); - Map plans = recordedPlans(turn, snapshot); - if (recorded == null && response == null && plans.isEmpty()) { + Map plans = recordedEntries(turn, snapshot, PLAN_PREFIX); + Map attempts = recordedEntries(turn, snapshot, ATTEMPT_PREFIX); + if (recorded == null && response == null && plans.isEmpty() && attempts.isEmpty()) { return Optional.empty(); } if (recorded == null) { @@ -141,9 +152,12 @@ public Optional replay(Turn turn, ChatRequest request, RunSnapshot new RunStoreConflictException( "Journal entry id identifies different data: " + turn.runId() + "/" + turn.turnId())); } + reservedAttempts(turn, attempts, hash); if (response == null) { - throw new ModelTurnJournalException("Model turn " + turn.turnId() - + " was issued but its response was never recorded; the turn cannot be replayed automatically"); + if (!plans.isEmpty()) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + " has tool plans without a response"); + } + return Optional.empty(); } validateEntry(response, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, ModelTurnCodec.RESPONSE_TYPE); ChatResponse decoded = ModelTurnCodec.decodeResponse(response.payload().json()); @@ -165,13 +179,67 @@ public Optional replay(Turn turn, ChatRequest request, RunSnapshot return Optional.of(decoded); } - /** Prepares the request fingerprint; the runtime commits it before calling the provider. */ + /** + * Prepares only the request fingerprint. This low-level operation does not reserve a + * model call; an invocation path must use + * {@link #prepareAttempt(Turn, ChatRequest, RunSnapshot)} before calling the provider. + */ public List prepareRequest(Turn turn, ChatRequest request) { return List.of(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + "#req", RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.REQUEST_TYPE, ModelTurnCodec.encodeRequest(request)))); } + /** Prepares a model-call reservation using {@link #DEFAULT_MAX_ATTEMPTS}. */ + public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot snapshot) { + return prepareAttempt(turn, request, snapshot, DEFAULT_MAX_ATTEMPTS); + } + + /** + * Prepares the next model-call reservation and, for a new turn, its request fingerprint. + * The runtime must commit the entire batch with this snapshot's revision, retaining the + * checkpoint, before invoking the provider. Only a successful commit authorizes one call; + * preparation alone never consumes or grants an attempt. On conflict, reload and replay. + * + *

    + * The limit includes the first call. A legacy request without attempt records counts as + * one reservation. Reservations are never refunded for a lost response or an interrupted + * invocation; changing the limit does not reset the stored count. Streaming delta tokens + * are unrelated to this count. This limit does not replace runtime-owned run budgets. + * @param turn the logical model call + * @param request the effective provider request + * @param snapshot the committed view used for replay and the reservation commit + * @param maxAttempts positive total invocation limit; 1 disables recovery re-asking + * @return immutable entries to commit before at most one provider invocation + * @throws ModelTurnJournalException if the turn already has a result, its data is + * incompatible, or its model-call budget is exhausted + * @throws IllegalArgumentException if maxAttempts is not positive + */ + public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot snapshot, int maxAttempts) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be positive"); + } + if (replay(turn, request, snapshot).isPresent()) { + throw new ModelTurnJournalException("Model turn " + turn.turnId() + " already has a recorded response"); + } + String hash = ModelTurnCodec.requestHash(request); + boolean hasRequest = snapshot.journal().containsKey(turn.turnId() + "#req"); + int used = hasRequest ? reservedAttempts(turn, recordedEntries(turn, snapshot, ATTEMPT_PREFIX), hash) : 0; + if (used >= maxAttempts) { + throw new ModelTurnJournalException( + "Model-call budget exhausted for turn " + turn.turnId() + " (maxAttempts=" + maxAttempts + ")"); + } + List entries = new ArrayList<>(); + if (!hasRequest) { + entries.addAll(prepareRequest(turn, request)); + } + int ordinal = used + 1; + entries.add(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + ATTEMPT_PREFIX + ordinal, + RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.ATTEMPT_TYPE, + ModelTurnCodec.encodeAttempt(ordinal, hash)))); + return List.copyOf(entries); + } + /** * Prepares the terminal response and every tool plan without publishing any records. * The runtime must commit this entire batch with the corresponding checkpoint through @@ -220,9 +288,9 @@ public List prepareDelta(Turn turn, String attempt, int seq, String te } /** - * A fresh identifier for one streaming attempt. Every subscription that starts a new - * provider call must use its own token, so an abandoned attempt cannot overwrite the - * text of the attempt that replaced it. + * A fresh diagnostic token for one streaming attempt, not a model-call reservation. + * Every subscription that starts a new provider call must use its own token, so an + * abandoned attempt cannot overwrite the text of the attempt that replaced it. */ public static String newAttempt() { return UUID.randomUUID().toString(); @@ -250,20 +318,32 @@ private static String planId(Turn turn, int ordinal) { } /** - * Plan entries of this turn, keyed by id. Any entry under the turn's plan prefix counts, - * so an extra entry is visible as a size mismatch rather than being silently ignored. - * The suffix must be digits: a turn id may itself contain {@code #task.}, and without - * that check another turn's entries would leak into this one. + * Numbered entries of this turn, keyed by id. Extra ordinals remain visible to validation. + * The suffix must be digits: a turn id may itself contain {@code #task.} or + * {@code #attempt.}, and without that check another turn's entries would leak into this one. */ - private Map recordedPlans(Turn turn, RunSnapshot snapshot) { - String prefix = turn.turnId() + PLAN_PREFIX; - Map plans = new HashMap<>(); + private Map recordedEntries(Turn turn, RunSnapshot snapshot, String entryPrefix) { + String prefix = turn.turnId() + entryPrefix; + Map entries = new HashMap<>(); snapshot.journal().forEach((id, entry) -> { if (id.startsWith(prefix) && isOrdinal(id.substring(prefix.length()))) { - plans.put(id, entry); + entries.put(id, entry); + } + }); + return entries; + } + + private int reservedAttempts(Turn turn, Map attempts, String requestHash) { + // A request written before attempt journaling still reserves its first invocation. + int used = attempts.size() + (attempts.containsKey(turn.turnId() + ATTEMPT_PREFIX + 1) ? 0 : 1); + attempts.forEach((id, entry) -> { + validateEntry(entry, id, RunEntry.Kind.MODEL_CALL, ModelTurnCodec.ATTEMPT_TYPE); + int ordinal = ModelTurnCodec.decodeAttemptOrdinal(entry.payload().json(), requestHash); + if (ordinal > used || !id.equals(turn.turnId() + ATTEMPT_PREFIX + ordinal)) { + throw new ModelTurnJournalException("Invalid or non-contiguous model attempt entry " + id); } }); - return plans; + return used; } private static boolean isOrdinal(String suffix) { diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java index 89ca3aba9..3700f1e6c 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java @@ -18,11 +18,11 @@ * Engine-neutral journaling of model turns. * *

    - * The non-deterministic result of a model call — the assistant message, its tool calls, - * finish reason and usage — cannot be regenerated. These types record it, together with a - * stable identity for every planned tool call. Preparation is stateless and has no storage + * A subsequent model call can produce a different assistant message or tool plan. These + * types record the response, finish reason and usage, together with a stable identity for + * every planned tool call. Preparation is stateless and has no storage * side effects. The runtime persists the data before dispatching tools; recovery then - * validates and replays the recorded result instead of asking the model again. + * validates and replays a completed turn's recorded result instead of asking the model again. * *

    * The contract deliberately knows no graph, session, scheduler or streaming library: it @@ -33,14 +33,22 @@ * *

    * The runtime explicitly enables journaling and owns the store, lease and latest committed - * snapshot. It commits a prepared request fingerprint before calling the model, then - * commits the full response and plan batch together with the corresponding checkpoint + * snapshot. It commits a prepared request fingerprint and attempt reservation before calling + * the model, then commits the full response and plan batch with the corresponding checkpoint * through {@link io.github.agentic.ai.runtime.execution.run.RunStore#commit} before * dispatching the first tool. Planned TASK_CALL entries are journal data, not task lifecycle * updates in {@code RunCommit.tasks}. * *

    - * All writes share one runtime coordinator. Request fingerprints and diagnostic deltas + * A turn without a response or plan can reserve another attempt for the same request. + * The default limit is two total reservations per turn, including the first invocation; + * the caller can supply any positive limit. Each reservation is journaled before its call, + * and remains consumed after interruption or a lost response. Legacy request-only records + * count as one reservation. Streaming delta tokens do not affect this count, and attempts + * do not change task identities. Run-wide, token and cost budgets remain runtime concerns. + * + *

    + * All writes share one runtime coordinator. Fingerprints, reservations and diagnostic deltas * retain the checkpoint. The runtime settles in-flight delta commits before submitting the * terminal response, may drop unsubmitted deltas, and propagates storage failures by default. * An uncertain commit outcome requires resolving the stored state before continuing from a @@ -50,8 +58,9 @@ * Replay checks a single supplied snapshot for compatible request/response records and an * exact matching tool plan. It cannot prove that records were committed together, interpret * application checkpoints or detect stale reads. An empty replay must be followed by a - * request commit using that snapshot's revision; a conflict requires reloading and replaying - * again. Durable-mode admission, definition and identity recovery, execution positions and + * successful attempt reservation using that snapshot's revision before a provider call; + * a conflict requires reloading and replaying again. Durable-mode admission, definition and + * identity recovery, execution positions and * tool receipts remain runtime responsibilities. An in-memory reference store only models * recovery within one process. */ diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index 2e5c61ba3..8ddb5c5c9 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -18,7 +18,6 @@ import io.github.agentic.ai.api.chat.ChatMessage; import io.github.agentic.ai.api.chat.ChatRequest; import io.github.agentic.ai.api.chat.ChatResponse; -import io.github.agentic.ai.api.chat.ChatRole; import io.github.agentic.ai.api.chat.FinishReason; import io.github.agentic.ai.api.chat.ToolCall; import io.github.agentic.ai.api.chat.ToolDefinition; @@ -43,6 +42,9 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; import java.time.Duration; import java.util.ArrayList; @@ -64,517 +66,315 @@ class ModelTurnRecorderTest { private static final String RUN_ID = "run-1"; - private static final List FIRST_TURN = List.of(ChatMessage.user("search for argi")); + private static final ModelTurnRecorder.Turn TURN = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); private static final List TOOLS = List.of( new ToolDefinition("search", "search the web", "{\"type\":\"object\"}")); + private static final ChatRequest REQUEST = new ChatRequest(List.of(ChatMessage.user("search for argi")), + TOOLS, null, Map.of()); + + private static final ChatResponse RESPONSE = toolCallingResponse("call-1"); + private final MemoryRunStore store = new MemoryRunStore(); + private RunStore runtimeStore = store; + + private ModelTurnRecorder recorder = new ModelTurnRecorder(); + private RunSpec run; private RunLease lease; - private RunStore runtimeStore = store; - private RunSnapshot snapshot; - private ModelTurnRecorder recorder; - @BeforeEach void setUp() { TrustedExecutionContext context = TrustedExecutionContext.builder() - .tenant(new TenantReference("iam", "tenant")) - .principal(new PrincipalReference("iam", "user")) - .runId(RUN_ID) - .correlationId("trace") - .build(); + .tenant(new TenantReference("iam", "tenant")) + .principal(new PrincipalReference("iam", "user")) + .runId(RUN_ID) + .correlationId("trace") + .build(); run = RunSpec.from(context, "app", "session", "submission", DefinitionReference.of(DefinitionKind.AGENT, "agent", "1"), new RunPayload(1, "test.input", "{}")); store.submit(run, context); lease = store.acquire(run.namespace(), RUN_ID, UUID.randomUUID(), Duration.ofMinutes(1)); snapshot = store.commit(run.namespace(), RUN_ID, lease, - new RunCommit(0, RunState.RUNNING, - new RunPayload(1, "test.checkpoint", "{\"phase\":\"MODEL_READY\"}"), List.of(), List.of(), - List.of())); - recorder = new ModelTurnRecorder(); + new RunCommit(0, RunState.RUNNING, new RunPayload(1, "test.checkpoint", "{\"phase\":\"MODEL_READY\"}"), + List.of(), List.of(), List.of())); } @Test - void preparesOnlyTheRequestFingerprintWithoutWriting() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + void preparationIsImmutableAndHasNoStorageSideEffects() { RunSnapshot before = snapshot; + List> batches = List.of(recorder.prepareRequest(TURN, REQUEST), + recorder.prepareAttempt(TURN, REQUEST, snapshot), recorder.prepareResponse(TURN, RESPONSE, TOOLS), + recorder.prepareDelta(TURN, ModelTurnRecorder.newAttempt(), 0, "hello")); - List entries = recorder.prepareRequest(turn, request(FIRST_TURN)); - - assertThat(entries).hasSize(1); - assertThatThrownBy(entries::clear).isInstanceOf(UnsupportedOperationException.class); - RunEntry recorded = entries.get(0); - assertThat(recorded.id()).isEqualTo("agent:0#req"); - assertThat(recorded.schemaVersion()).isEqualTo(1); - assertThat(recorded.kind()).isEqualTo(RunEntry.Kind.MODEL_CALL); - assertThat(recorded.payload().type()).isEqualTo(ModelTurnCodec.REQUEST_TYPE); - // The request body is never persisted: only the fingerprint is. - assertThat(recorded.payload().json()).contains("requestHash").doesNotContain("search for argi"); + for (List batch : batches) { + assertThatThrownBy(batch::clear).isInstanceOf(UnsupportedOperationException.class); + } + assertThat(recorder.prepareAttempt(TURN, REQUEST, snapshot)).isEqualTo(batches.get(1)); assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } @Test - void equivalentToolArgumentsProduceTheSameHashAndRecord() { - assertThat(ModelTurnCodec.canonicalArguments("{\"q\":\"x\",\"p\":\"y\"}")).isEqualTo("{\"p\":\"y\",\"q\":\"x\"}"); - assertThat(ModelTurnCodec.canonicalArguments("{ \"p\" : \"y\", \"q\" : \"x\" }")) - .isEqualTo("{\"p\":\"y\",\"q\":\"x\"}"); - - String first = ModelTurnCodec.encodeToolPlan(new ToolCall("call-a", "search", "{\"q\":\"x\",\"p\":\"y\"}"), - TOOLS.get(0), 0); + void toolPlansIgnoreFormattingAndProviderCallIdsWhilePreservingArrayOrder() { + String first = ModelTurnCodec.encodeToolPlan( + new ToolCall("call-a", "search", "{\"q\":\"x\",\"p\":\"y\"}"), TOOLS.get(0), 0); String second = ModelTurnCodec.encodeToolPlan( new ToolCall("call-b", "search", "{ \"p\" : \"y\", \"q\" : \"x\" }"), TOOLS.get(0), 0); - // Formatting and the provider's call id must not change the recorded plan. assertThat(second).isEqualTo(first); - assertThat(first).contains("\"arguments\":\"{\\\"p\\\":\\\"y\\\",\\\"q\\\":\\\"x\\\"}\""); - } - - @Test - void arrayOrderAndMalformedArgumentsArePreserved() { - // Array order is positional, so it carries meaning and must not be sorted. assertThat(ModelTurnCodec.canonicalArguments("{\"tags\":[\"b\",\"a\"]}")) - .isEqualTo("{\"tags\":[\"b\",\"a\"]}"); - // Argument validation is opt-in, so an unparsable argument must pass through. - assertThat(ModelTurnCodec.canonicalArguments("not json at all")).isEqualTo("not json at all"); - assertThat(ModelTurnCodec.canonicalArguments("{}")).isEqualTo("{}"); - assertThat(ModelTurnCodec.canonicalArguments(null)).isNull(); - } - - @Test - void equivalentToolArgumentsInHistoryDoNotChangeTheRequestFingerprint() { - ChatMessage assistant = new ChatMessage(ChatRole.ASSISTANT, List.of(), - List.of(new ToolCall("call-a", "search", "{\"q\":\"x\",\"p\":\"y\"}")), null, null, Map.of()); - ChatMessage reformatted = new ChatMessage(ChatRole.ASSISTANT, List.of(), - List.of(new ToolCall("call-a", "search", "{ \"p\":\"y\", \"q\":\"x\" }")), null, null, Map.of()); - List first = List.of(ChatMessage.user("go"), assistant); - List second = List.of(ChatMessage.user("go"), reformatted); - - assertThat(ModelTurnCodec.requestHash(request(second))).isEqualTo(ModelTurnCodec.requestHash(request(first))); - } - - @Test - void preparingTheResponseReturnsAnImmutableBatchWithoutWriting() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunSnapshot before = snapshot; - - List entries = recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS); - - assertThat(entries).extracting(RunEntry::id).containsExactly("agent:0#resp", "agent:0#task.0"); - assertThatThrownBy(entries::clear).isInstanceOf(UnsupportedOperationException.class); - assertThat(snapshot).isSameAs(before); - assertThat(store.find(run.namespace(), RUN_ID)).contains(before); + .isEqualTo("{\"tags\":[\"b\",\"a\"]}"); } @Test - void commitsTheResponseAndItsPlanWithCheckpoint() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - commitRequest(turn, request(FIRST_TURN)); - long revision = snapshot.revision(); - - commitResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); - - assertThat(snapshot.revision()).isEqualTo(revision + 1); - assertThat(snapshot.checkpoint()).isEqualTo(checkpoint(turn)); - assertThat(snapshot.tasks()).isEmpty(); - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); - RunEntry plan = find("agent:0#task.0").orElseThrow(); - assertThat(plan.kind()).isEqualTo(RunEntry.Kind.TASK_CALL); - assertThat(plan.payload().type()).isEqualTo(ModelTurnCodec.PLAN_TYPE); - assertThat(plan.payload().json()).contains("\"toolName\":\"search\"", "\"ordinal\":0", "\"argumentsHash\""); - // The provider's call id is protocol correlation, never task identity. - assertThat(plan.payload().json()).doesNotContain("provider-call-1"); - } - - @Test - void referenceExecutionDispatchesOnlyAfterTheWholePlanAndCheckpointAreCommitted() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - long revision = snapshot.revision(); - AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); - List calls = List.of(new ToolCall("provider-1", "search", "{\"q\":\"first\"}"), - new ToolCall("provider-2", "search", "{\"q\":\"second\"}")); + void referenceDispatchesOnlyAfterTheResponseAllPlansAndCheckpointAreCommitted() { + List calls = List.of(new ToolCall("first", "search", "{\"q\":\"first\"}"), + new ToolCall("second", "search", "{\"q\":\"second\"}")); ChatResponse response = new ChatResponse("response", "model", List.of(ChatMessage.assistant("", calls)), calls, FinishReason.TOOL_CALLS, null, Map.of()); + AtomicInteger tools = new AtomicInteger(); - executeReference(turn, request(FIRST_TURN), () -> { - modelCalls.incrementAndGet(); + executeReference(() -> { + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#attempt.1"); return response; }, call -> { RunSnapshot committed = store.find(run.namespace(), RUN_ID).orElseThrow(); - assertThat(committed.checkpoint()).isEqualTo(checkpoint(turn)); - assertThat(committed.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0", - "agent:0#task.1"); - assertThat(committed.tasks()).isEmpty(); - toolCalls.incrementAndGet(); + assertThat(committed.checkpoint()).isEqualTo(checkpoint()); + assertThat(committed.journal()).containsOnlyKeys("agent:0#req", "agent:0#attempt.1", + "agent:0#resp", "agent:0#task.0", "agent:0#task.1"); + tools.incrementAndGet(); }); - assertThat(modelCalls.get()).isEqualTo(1); - assertThat(toolCalls.get()).isEqualTo(2); - assertThat(snapshot.revision()).isEqualTo(revision + 2); + assertThat(tools.get()).isEqualTo(2); } - @Test - void requestCommitFailurePreventsModelAndToolCalls() { - IllegalStateException unavailable = new IllegalStateException("request storage unavailable"); - runtimeStore = new FailingRunStore(store, ModelTurnCodec.REQUEST_TYPE, false, unavailable); - RunSnapshot before = snapshot; + @ParameterizedTest + @CsvSource({ "argi.model-request, 0", "argi.model-attempt, 0", "argi.model-response, 1" }) + void commitFailurePreventsSubsequentSideEffects(String payloadType, int expectedModelCalls) { + IllegalStateException unavailable = new IllegalStateException("storage unavailable"); + runtimeStore = new FailingRunStore(store, payloadType, false, unavailable); + RunPayload before = snapshot.checkpoint(); AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); + AtomicInteger tools = new AtomicInteger(); - assertThatThrownBy(() -> executeReference(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), - request(FIRST_TURN), () -> { - modelCalls.incrementAndGet(); - return toolCallingResponse("call-1"); - }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); + assertThatThrownBy(() -> executeReference(countedModel(modelCalls), call -> tools.incrementAndGet())) + .isSameAs(unavailable); - assertThat(modelCalls.get()).isZero(); - assertThat(toolCalls.get()).isZero(); - assertThat(snapshot).isSameAs(before); - assertThat(store.find(run.namespace(), RUN_ID)).contains(before); + assertThat(modelCalls.get()).isEqualTo(expectedModelCalls); + assertThat(tools.get()).isZero(); + assertThat(snapshot.checkpoint()).isEqualTo(before); + assertThat(store.find(run.namespace(), RUN_ID)).contains(snapshot); } @Test - void responseCommitFailurePreventsToolCallsAndRetainsTheRequestCheckpoint() { - IllegalStateException unavailable = new IllegalStateException("response storage unavailable"); - runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, false, unavailable); - RunSnapshot before = snapshot; + void lostAttemptConfirmationDoesNotInvokeTheModelOrRefundTheReservation() { + IllegalStateException lost = new IllegalStateException("attempt confirmation lost"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.ATTEMPT_TYPE, true, lost); AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); + AtomicInteger tools = new AtomicInteger(); + RunSnapshot before = snapshot; - assertThatThrownBy(() -> executeReference(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), - request(FIRST_TURN), () -> { - modelCalls.incrementAndGet(); - return toolCallingResponse("call-1"); - }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); + assertThatThrownBy(() -> executeReference(1, countedModel(modelCalls), call -> tools.incrementAndGet())) + .isSameAs(lost); + assertThat(snapshot).isSameAs(before); - assertThat(modelCalls.get()).isEqualTo(1); - assertThat(toolCalls.get()).isZero(); - assertThat(snapshot.revision()).isEqualTo(before.revision() + 1); - assertThat(snapshot.checkpoint()).isEqualTo(before.checkpoint()); - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req"); - assertThat(store.find(run.namespace(), RUN_ID)).contains(snapshot); + runtimeStore = store; + reload(); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#attempt.1"); + assertThatThrownBy(() -> executeReference(1, countedModel(modelCalls), call -> tools.incrementAndGet())) + .isInstanceOf(ModelTurnJournalException.class).hasMessageContaining("budget exhausted"); + assertThat(modelCalls.get()).isZero(); + assertThat(tools.get()).isZero(); } @Test - void lostResponseCommitConfirmationPreventsDispatchUntilReloadAndReplay() { - IllegalStateException lostConfirmation = new IllegalStateException("commit confirmation lost"); - runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, true, lostConfirmation); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + void lostResponseConfirmationRecoversTheSamePlanWithoutAnotherModelCall() { + IllegalStateException lost = new IllegalStateException("response confirmation lost"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, true, lost); AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); - Supplier model = () -> { - modelCalls.incrementAndGet(); - return toolCallingResponse("call-1"); - }; + AtomicInteger tools = new AtomicInteger(); - assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), model, - call -> toolCalls.incrementAndGet())).isSameAs(lostConfirmation); + assertThatThrownBy(() -> executeReference(countedModel(modelCalls), call -> tools.incrementAndGet())) + .isSameAs(lost); + assertThat(tools.get()).isZero(); - assertThat(modelCalls.get()).isEqualTo(1); - assertThat(toolCalls.get()).isZero(); - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req"); - RunSnapshot committed = store.find(run.namespace(), RUN_ID).orElseThrow(); - assertThat(committed.revision()).isEqualTo(snapshot.revision() + 1); - assertThat(committed.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); - - // Reload instead of assuming that a thrown commit exception means no write occurred. - snapshot = committed; - recorder = new ModelTurnRecorder(); - executeReference(turn, request(FIRST_TURN), model, call -> toolCalls.incrementAndGet()); + runtimeStore = store; + reload(); + RunEntry plan = snapshot.journal().get("agent:0#task.0"); + RunSnapshot committed = snapshot; + ChatResponse recovered = executeReference(1, () -> { + modelCalls.incrementAndGet(); + return toolCallingResponse("different-provider-id"); + }, call -> tools.incrementAndGet()); assertThat(modelCalls.get()).isEqualTo(1); - assertThat(toolCalls.get()).isEqualTo(1); + assertThat(tools.get()).isEqualTo(1); + assertThat(recovered.toolCalls()).isEqualTo(RESPONSE.toolCalls()); + assertThat(snapshot.journal()).containsEntry("agent:0#task.0", plan); assertThat(snapshot).isSameAs(committed); } @Test - void staleSnapshotRequestCommitConflictsBeforeCallingTheModel() { + void aStaleSnapshotCannotAuthorizeAnotherModelCall() { RunSnapshot stale = snapshot; - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - commitRequest(turn, request(FIRST_TURN)); - commitResponse(turn, toolCallingResponse("call-1"), TOOLS); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); RunSnapshot committed = snapshot; snapshot = stale; AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); - assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), () -> { - modelCalls.incrementAndGet(); - return toolCallingResponse("call-2"); - }, call -> toolCalls.incrementAndGet())).isInstanceOf(RunStoreConflictException.class); + assertThatThrownBy(() -> executeReference(countedModel(modelCalls), call -> { + throw new AssertionError("No tool may run after a stale reservation"); + })).isInstanceOf(RunStoreConflictException.class); assertThat(modelCalls.get()).isZero(); - assertThat(toolCalls.get()).isZero(); assertThat(snapshot).isSameAs(stale); assertThat(store.find(run.namespace(), RUN_ID)).contains(committed); } - @Test - void replaysCommittedPlanAfterRecorderAndSnapshotAreReloadedWithoutCallingTheModel() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "planner/visit-3/model-2"); - commitRequest(turn, request(FIRST_TURN)); - commitResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); - RunEntry planned = find(turn.turnId() + "#task.0").orElseThrow(); - long revision = snapshot.revision(); - - // Recreate the execution objects after plan persistence, before tool dispatch. - snapshot = store.find(run.namespace(), RUN_ID).orElseThrow(); - recorder = new ModelTurnRecorder(); + @ParameterizedTest + @ValueSource(ints = { 1, 2, 3 }) + void committedReservationsSurviveReloadsAndEnforceTheConfiguredLimit(int maxAttempts) { + for (int ordinal = 1; ordinal <= maxAttempts; ordinal++) { + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, maxAttempts)); + reload(); + assertThat(snapshot.journal()).containsKey("agent:0#attempt." + ordinal); + } AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); - ChatResponse replayed = executeReference( - new ModelTurnRecorder.Turn(RUN_ID, "planner/visit-3/model-2"), request(FIRST_TURN), () -> { - modelCalls.incrementAndGet(); - return toolCallingResponse("provider-call-2"); - }, call -> toolCalls.incrementAndGet()); + AtomicInteger tools = new AtomicInteger(); + + assertThatThrownBy(() -> executeReference(maxAttempts, countedModel(modelCalls), + call -> tools.incrementAndGet())).isInstanceOf(ModelTurnJournalException.class) + .hasMessageContaining("budget exhausted"); assertThat(modelCalls.get()).isZero(); - assertThat(toolCalls.get()).isEqualTo(1); - assertThat(snapshot.revision()).isEqualTo(revision); - assertThat(find(turn.turnId() + "#task.0")).contains(planned); - assertThat(snapshot.checkpoint()).isEqualTo(checkpoint(turn)); - assertThat(snapshot.journal()).containsOnlyKeys("planner/visit-3/model-2#req", - "planner/visit-3/model-2#resp", "planner/visit-3/model-2#task.0"); - assertThat(replayed.toolCalls()) - .containsExactly(new ToolCall("provider-call-1", "search", "{\"q\":\"argi\"}")); + assertThat(tools.get()).isZero(); } @Test - void rejectsReplayWhenTheRecordedRequestDiffers() { - commitRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); - commitResponse(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), toolCallingResponse("provider-call-1"), TOOLS); + void aLostResponseCanBeReaskedOnceByDefaultAfterReload() { + IllegalStateException unavailable = new IllegalStateException("response storage unavailable"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, false, unavailable); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger tools = new AtomicInteger(); + Supplier model = () -> toolCallingResponse("provider-call-" + modelCalls.incrementAndGet()); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - List different = List.of(ChatMessage.user("a different instruction")); + assertThatThrownBy(() -> executeReference(model, call -> tools.incrementAndGet())).isSameAs(unavailable); + assertThat(tools.get()).isZero(); - assertThatThrownBy(() -> recorder.replay(turn, request(different), snapshot)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("different request") - .hasCauseInstanceOf(RunStoreConflictException.class); + runtimeStore = store; + reload(); + ChatResponse recovered = executeReference(model, call -> tools.incrementAndGet()); + + assertThat(modelCalls.get()).isEqualTo(2); + assertThat(tools.get()).isEqualTo(1); + assertThat(recovered.toolCalls()).extracting(ToolCall::id).containsExactly("provider-call-2"); + assertThat(snapshot.journal()).containsKeys("agent:0#attempt.1", "agent:0#attempt.2", "agent:0#task.0"); } @Test - void anInterruptedTurnFailsClosedInsteadOfAskingAgain() { - commitRequest(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN)); + void aLegacyRequestCountsAsTheFirstReservation() { + commit(null, recorder.prepareRequest(TURN, REQUEST)); + List recovery = recorder.prepareAttempt(TURN, REQUEST, snapshot); + assertThat(recovery).extracting(RunEntry::id).containsExactly("agent:0#attempt.2"); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); + commit(null, recovery); + reload(); - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), snapshot)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("never recorded"); - assertThat(find("agent:0#resp")).isEmpty(); + assertThatThrownBy(() -> recorder.prepareAttempt(TURN, REQUEST, snapshot)) + .isInstanceOf(ModelTurnJournalException.class).hasMessageContaining("budget exhausted"); } @Test - void rejectsBlankTurnIdentityComponents() { - for (String invalid : new String[] { null, "", " \t" }) { - assertThatThrownBy(() -> new ModelTurnRecorder.Turn(invalid, "turn")) - .isInstanceOf(IllegalArgumentException.class); - assertThatThrownBy(() -> new ModelTurnRecorder.Turn(RUN_ID, invalid)) - .isInstanceOf(IllegalArgumentException.class); - } - } + void differentTurnsHaveIndependentAttemptBudgets() { + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, 1)); + ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(RUN_ID, "next"); - @Test - void replayRejectsASnapshotFromAnotherRun() { - assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn("another-run", "agent:0"), - request(FIRST_TURN), snapshot)).isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("Run snapshot does not match"); + assertThat(recorder.prepareAttempt(next, REQUEST, snapshot, 1)).extracting(RunEntry::id) + .containsExactly("next#req", "next#attempt.1"); } - @Test - void responseWithoutRequestFailsBeforeModelOrToolCalls() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - commitResponse(turn, toolCallingResponse("call-1"), TOOLS); - AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); - - assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), () -> { - modelCalls.incrementAndGet(); - return toolCallingResponse("call-2"); - }, call -> toolCalls.incrementAndGet())).isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("missing its request fingerprint"); + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void aDifferentRequestCannotReplaceAnInterruptedOrCompletedTurn(boolean completed) { + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + if (completed) { + commit(checkpoint(), recorder.prepareResponse(TURN, RESPONSE, TOOLS)); + } + ChatRequest changed = new ChatRequest(List.of(ChatMessage.user("different request")), TOOLS, null, Map.of()); - assertThat(modelCalls.get()).isZero(); - assertThat(toolCalls.get()).isZero(); + assertThatThrownBy(() -> recorder.prepareAttempt(TURN, changed, snapshot)) + .isInstanceOf(ModelTurnJournalException.class).hasCauseInstanceOf(RunStoreConflictException.class); } @Test - void replayRejectsAnOrphanPlanInsteadOfTreatingTheTurnAsNew() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunEntry plan = recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS).get(1); - RunSnapshot orphaned = snapshotWithJournal(List.of(plan)); - - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), orphaned)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("missing its request fingerprint"); + void replayRejectsIncompleteExtraOrMismatchedPlans() { + List complete = completeEntries(); + RunEntry request = complete.get(0); + RunEntry response = complete.get(1); + RunEntry plan = complete.get(2); + RunEntry extra = new RunEntry(1, "agent:0#task.1", plan.kind(), plan.payload()); + RunEntry changed = new RunEntry(1, plan.id(), plan.kind(), new RunPayload(1, ModelTurnCodec.PLAN_TYPE, + ModelTurnCodec.encodeToolPlan(new ToolCall("call-1", "search", "{\"q\":\"different\"}"), + TOOLS.get(0), 0))); + List> invalid = List.of(List.of(response, plan), List.of(request, plan), + List.of(request, response), List.of(request, response, plan, extra), + List.of(request, response, changed)); + + for (List entries : invalid) { + RunSnapshot recorded = snapshotWithJournal(entries); + assertThatThrownBy(() -> recorder.replay(TURN, REQUEST, recorded)) + .isInstanceOf(ModelTurnJournalException.class); + } } @Test - void replayRejectsIncompatibleEntryKindsTypesAndPayloadVersions() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - List complete = completeEntries(turn); + void replayChecksEntryKindsTypesAndVersions() { + List complete = completeEntries(); + complete.add(recorder.prepareAttempt(TURN, REQUEST, snapshot).get(1)); for (int index = 0; index < complete.size(); index++) { RunEntry original = complete.get(index); List incompatible = List.of( new RunEntry(1, original.id(), RunEntry.Kind.EVENT, original.payload()), - new RunEntry(1, original.id(), original.kind(), new RunPayload(1, "other.type", "{}")), + new RunEntry(1, original.id(), original.kind(), + new RunPayload(1, "other.type", original.payload().json())), new RunEntry(1, original.id(), original.kind(), new RunPayload(2, original.payload().type(), original.payload().json()))); for (RunEntry replacement : incompatible) { List entries = new ArrayList<>(complete); entries.set(index, replacement); RunSnapshot invalid = snapshotWithJournal(entries); - - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), invalid)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("Incompatible journal entry " + original.id()); + assertThatThrownBy(() -> recorder.replay(TURN, REQUEST, invalid)) + .isInstanceOf(ModelTurnJournalException.class); } } } @Test - void replayRejectsAnEntryWhoseIdDoesNotMatchItsJournalKey() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunSnapshot complete = snapshotWithJournal(completeEntries(turn)); - Map entries = new HashMap<>(complete.journal()); - RunEntry request = entries.get("agent:0#req"); - entries.put(request.id(), new RunEntry(1, "another-turn#req", request.kind(), request.payload())); - RunSnapshot invalid = new RunSnapshot(complete.spec(), complete.revision(), complete.state(), - complete.checkpoint(), complete.tasks(), entries, complete.outbox()); - - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), invalid)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("Incompatible journal entry agent:0#req"); - } - - @Test - void replayRejectsARequestRecordWithoutAFingerprint() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunEntry invalid = new RunEntry(1, "agent:0#req", RunEntry.Kind.MODEL_CALL, - new RunPayload(1, ModelTurnCodec.REQUEST_TYPE, "{}")); - RunSnapshot recorded = snapshotWithJournal(List.of(invalid)); - - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), recorded)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("invalid request fingerprint"); - } - - @Test - void replayRejectsMissingExtraAndNonContiguousPlans() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - List complete = completeEntries(turn); - RunEntry plan = complete.get(2); - RunEntry extra = new RunEntry(1, "agent:0#task.1", plan.kind(), plan.payload()); - RunEntry alias = new RunEntry(1, "agent:0#task.00", plan.kind(), plan.payload()); - List> incomplete = List.of(complete.subList(0, 2), - List.of(complete.get(0), complete.get(1), extra), - List.of(complete.get(0), complete.get(1), plan, extra), - List.of(complete.get(0), complete.get(1), plan, alias)); - for (List entries : incomplete) { - RunSnapshot recorded = snapshotWithJournal(entries); + void inconsistentAttemptRecordsCannotResetTheBudget() { + RunEntry request = recorder.prepareRequest(TURN, REQUEST).get(0); + String hash = ModelTurnCodec.requestHash(REQUEST); + List invalid = List.of( + new RunEntry(1, "agent:0#attempt.3", RunEntry.Kind.MODEL_CALL, + new RunPayload(1, ModelTurnCodec.ATTEMPT_TYPE, ModelTurnCodec.encodeAttempt(3, hash))), + new RunEntry(1, "agent:0#attempt.1", RunEntry.Kind.MODEL_CALL, + new RunPayload(1, ModelTurnCodec.ATTEMPT_TYPE, ModelTurnCodec.encodeAttempt(1, "different")))); - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), recorded)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("tool plan"); + for (RunEntry attempt : invalid) { + RunSnapshot recorded = snapshotWithJournal(List.of(request, attempt)); + assertThatThrownBy(() -> recorder.prepareAttempt(TURN, REQUEST, recorded, 5)) + .isInstanceOf(ModelTurnJournalException.class); } } @Test - void replayRejectsPlanContentsThatDisagreeWithTheResponseOrDisclosedTools() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - List complete = completeEntries(turn); - RunEntry plan = complete.get(2); - String json = plan.payload().json(); - List changed = List.of(json.replace("\"ordinal\":0", "\"ordinal\":1"), - json.replace("\"toolName\":\"search\"", "\"toolName\":\"another-tool\""), - json.replace("\"argumentsHash\":\"", "\"argumentsHash\":\"changed-"), - json.replace("\"definitionVersion\":\"", "\"definitionVersion\":\"changed-"), - ModelTurnCodec.encodeToolPlan(new ToolCall("call-1", "search", "{\"q\":\"different\"}"), - TOOLS.get(0), 0)); - for (String changedJson : changed) { - RunEntry replacement = new RunEntry(1, plan.id(), plan.kind(), - new RunPayload(1, ModelTurnCodec.PLAN_TYPE, changedJson)); - RunSnapshot invalid = snapshotWithJournal(List.of(complete.get(0), complete.get(1), replacement)); - - assertThatThrownBy(() -> recorder.replay(turn, request(FIRST_TURN), invalid)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("tool plan does not match"); - } - } - - @Test - void replayScopesPlansToExactTurnIdsIncludingSeparatorLikeCharacters() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent.[0]+"); - ModelTurnRecorder.Turn other = new ModelTurnRecorder.Turn(RUN_ID, turn.turnId() + "#task.0"); - List entries = new ArrayList<>(completeEntries(turn)); - entries.addAll(completeEntries(other)); - RunSnapshot recorded = snapshotWithJournal(entries); - - assertThat(recorder.replay(turn, request(FIRST_TURN), recorded)).contains(toolCallingResponse("call-1")); - assertThat(recorder.replay(other, request(FIRST_TURN), recorded)).contains(toolCallingResponse("call-1")); - } - - @Test - void textOnlyResponseReplaysWithNoToolPlans() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - ChatResponse text = new ChatResponse("response", "model", List.of(ChatMessage.assistant("done")), - List.of(), FinishReason.STOP, null, Map.of()); - AtomicInteger modelCalls = new AtomicInteger(); - AtomicInteger toolCalls = new AtomicInteger(); - Supplier model = () -> { - modelCalls.incrementAndGet(); - return text; - }; - - assertThat(executeReference(turn, request(FIRST_TURN), model, call -> toolCalls.incrementAndGet())) - .isEqualTo(text); - assertThat(executeReference(turn, request(FIRST_TURN), model, call -> toolCalls.incrementAndGet())) - .isEqualTo(text); - - assertThat(modelCalls.get()).isEqualTo(1); - assertThat(toolCalls.get()).isZero(); - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp"); - } - - @Test - void replayValidatesCurrentContentsWithoutInterpretingTheCheckpointOrCommitHistory() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - commitRequest(turn, request(FIRST_TURN)); - List entries = recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS); - // Deliberately violate producer ordering to show that replay cannot prove atomic history. - commit(null, entries.subList(0, 1)); - commit(new RunPayload(1, "application.checkpoint", "{\"opaque\":true}"), entries.subList(1, entries.size())); - - assertThat(recorder.replay(turn, request(FIRST_TURN), snapshot)).contains(toolCallingResponse("call-1")); - } - - @Test - void providerCallIdsDoNotChangeThePlannedTaskIdentity() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - List first = recorder.prepareResponse(turn, toolCallingResponse("provider-call-1"), TOOLS); - List renamed = recorder.prepareResponse(turn, toolCallingResponse("provider-call-2"), TOOLS); - assertThat(renamed.stream().filter(entry -> entry.kind() == RunEntry.Kind.TASK_CALL).toList()) - .isEqualTo(first.stream().filter(entry -> entry.kind() == RunEntry.Kind.TASK_CALL).toList()); - - commitRequest(turn, request(FIRST_TURN)); - commit(checkpoint(turn), first); - assertThat(recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "agent:0"), request(FIRST_TURN), snapshot)) - .contains(toolCallingResponse("provider-call-1")); - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0"); - } - - @Test - void theSameTaskIdentityWithDifferentArgumentsConflictsWithoutUpdatingCheckpoint() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - commitRequest(turn, request(FIRST_TURN)); - commitResponse(turn, toolCallingResponse("call-1"), TOOLS); + void theSameTaskWithDifferentArgumentsConflictsWithoutUpdatingTheCheckpoint() { + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + commit(checkpoint(), recorder.prepareResponse(TURN, RESPONSE, TOOLS)); RunSnapshot before = snapshot; RunEntry changed = new RunEntry(1, "agent:0#task.0", RunEntry.Kind.TASK_CALL, new RunPayload(1, ModelTurnCodec.PLAN_TYPE, ModelTurnCodec.encodeToolPlan( @@ -582,190 +382,75 @@ void theSameTaskIdentityWithDifferentArgumentsConflictsWithoutUpdatingCheckpoint assertThatThrownBy(() -> commit(new RunPayload(1, "test.checkpoint", "{\"changed\":true}"), List.of(changed))).isInstanceOf(RunStoreConflictException.class); - assertThat(snapshot).isSameAs(before); assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } @Test - void streamedTextIsRecordedUnderOneStableAttempt() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - String attempt = ModelTurnRecorder.newAttempt(); - RunSnapshot before = snapshot; + void textOnlyResultsReplayWithoutToolPlans() { + ChatResponse text = new ChatResponse("response", "model", List.of(ChatMessage.assistant("done")), + List.of(), FinishReason.STOP, null, Map.of()); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger tools = new AtomicInteger(); + Supplier model = () -> { + modelCalls.incrementAndGet(); + return text; + }; - commit(null, recorder.prepareDelta(turn, attempt, 0, "he")); - commit(null, recorder.prepareDelta(turn, attempt, 1, "llo")); - assertThat(recorder.prepareDelta(turn, attempt, 2, "")).isEmpty(); - - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#progress." + attempt + "#delta.0", - "agent:0#progress." + attempt + "#delta.1"); - assertThat(snapshot.revision()).isEqualTo(before.revision() + 2); - assertThat(snapshot.checkpoint()).isEqualTo(before.checkpoint()); - // Progress is diagnostic only: it never produces a response or a plan. - assertThat(find("agent:0#resp")).isEmpty(); - assertThat(find("agent:0#task.0")).isEmpty(); - assertThat(recorder.replay(turn, request(FIRST_TURN), snapshot)).isEmpty(); + assertThat(executeReference(model, call -> tools.incrementAndGet())).isEqualTo(text); + assertThat(executeReference(model, call -> tools.incrementAndGet())).isEqualTo(text); + assertThat(modelCalls.get()).isEqualTo(1); + assertThat(tools.get()).isZero(); } @Test - void preparingDeltasIsImmutableAndHasNoStorageSideEffects() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunSnapshot before = snapshot; + void diagnosticDeltasRetainTheCheckpointUntilTheTerminalResponse() { + RunPayload before = snapshot.checkpoint(); String attempt = ModelTurnRecorder.newAttempt(); + AtomicInteger tools = new AtomicInteger(); - List entries = recorder.prepareDelta(turn, attempt, 0, "hello"); - - assertThat(entries).extracting(RunEntry::id).containsExactly("agent:0#progress." + attempt + "#delta.0"); - assertThatThrownBy(entries::clear).isInstanceOf(UnsupportedOperationException.class); - assertThat(recorder.prepareDelta(turn, attempt, 1, "")).isEmpty(); - assertThat(recorder.prepareDelta(turn, attempt, 1, null)).isEmpty(); - assertThat(snapshot).isSameAs(before); - assertThat(store.find(run.namespace(), RUN_ID)).contains(before); - } - - @Test - void referenceCommitsDeltasBeforeTheResponseUsingEachSuccessfulRevision() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - RunSnapshot before = snapshot; - String attempt = ModelTurnRecorder.newAttempt(); - AtomicInteger toolCalls = new AtomicInteger(); - - executeReference(turn, request(FIRST_TURN), () -> { - commit(null, recorder.prepareDelta(turn, attempt, 0, "he")); - commit(null, recorder.prepareDelta(turn, attempt, 1, "llo")); - assertThat(snapshot.revision()).isEqualTo(before.revision() + 3); - assertThat(snapshot.checkpoint()).isEqualTo(before.checkpoint()); - assertThat(find("agent:0#resp")).isEmpty(); - return toolCallingResponse("call-1"); + executeReference(() -> { + commit(null, recorder.prepareDelta(TURN, attempt, 0, "hello")); + assertThat(snapshot.checkpoint()).isEqualTo(before); + assertThat(snapshot.journal()).doesNotContainKeys("agent:0#resp", "agent:0#task.0"); + return RESPONSE; }, call -> { - assertThat(snapshot.checkpoint()).isEqualTo(checkpoint(turn)); - assertThat(snapshot.revision()).isEqualTo(before.revision() + 4); - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#resp", "agent:0#task.0", - "agent:0#progress." + attempt + "#delta.0", "agent:0#progress." + attempt + "#delta.1"); - toolCalls.incrementAndGet(); + assertThat(snapshot.checkpoint()).isEqualTo(checkpoint()); + tools.incrementAndGet(); }); - assertThat(toolCalls.get()).isEqualTo(1); - } - - @Test - void deltaCommitFailureIsPropagatedBeforeTheResponseAndToolDispatch() { - IllegalStateException unavailable = new IllegalStateException("delta storage unavailable"); - runtimeStore = new FailingRunStore(store, ModelTurnCodec.DELTA_TYPE, false, unavailable); - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:0"); - AtomicInteger toolCalls = new AtomicInteger(); - - assertThatThrownBy(() -> executeReference(turn, request(FIRST_TURN), () -> { - commit(null, recorder.prepareDelta(turn, ModelTurnRecorder.newAttempt(), 0, "hello")); - return toolCallingResponse("call-1"); - }, call -> toolCalls.incrementAndGet())).isSameAs(unavailable); - - assertThat(toolCalls.get()).isZero(); - assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req"); - assertThat(store.find(run.namespace(), RUN_ID)).contains(snapshot); - } - - @Test - void differentCallsWithTheSameRequestHaveIndependentRecords() { - ModelTurnRecorder.Turn first = new ModelTurnRecorder.Turn(RUN_ID, "first-call"); - ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(RUN_ID, "next-call"); - commitRequest(first, request(FIRST_TURN)); - commitResponse(first, toolCallingResponse("call-1"), TOOLS); - - assertThat(recorder.replay(next, request(FIRST_TURN), snapshot)).isEmpty(); - commitRequest(next, request(FIRST_TURN)); - commitResponse(next, toolCallingResponse("call-2"), TOOLS); - - assertThat(snapshot.journal()).containsOnlyKeys("first-call#req", "first-call#resp", "first-call#task.0", - "next-call#req", "next-call#resp", "next-call#task.0"); - } - - /** - * Regression guard for the defect that made summarisation fatal: a count over the - * conversation shrinks when history is summarised or trimmed, so the next call reused - * an ordinal that an earlier turn already owned and every later turn conflicted. - */ - @Test - void shrinkingTheConversationDoesNotCollideWithAnEarlierTurn() { - for (long ordinal = 0; ordinal < 3; ordinal++) { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "agent:" + ordinal); - commitRequest(turn, request(List.of(ChatMessage.user("question " + ordinal)))); - commitResponse(turn, toolCallingResponse("call-" + ordinal), TOOLS); - } - - // Summarisation replaced the history with a summary plus a short tail, so the - // conversation now holds fewer assistant messages than before. The engine supplies - // a new identity independently of that history. - List summarisedHistory = List.of(ChatMessage.system("summary of earlier turns"), - ChatMessage.user("question 3")); - ModelTurnRecorder.Turn next = new ModelTurnRecorder.Turn(RUN_ID, "after-summary"); - - assertThat(recorder.replay(next, request(summarisedHistory), snapshot)).isEmpty(); - commitRequest(next, request(summarisedHistory)); - assertThat(find("after-summary#req")).isPresent(); - } - - /** - * Documented limitation: a request rewritten non-deterministically between the original - * call and a restart - for example by a summarisation hook that asks a model for the - * summary - yields a different fingerprint for the same turn. The turn must fail closed - * rather than replay a response that was produced for other input. - */ - @Test - void aNonDeterministicRewriteOfTheSameTurnFailsClosed() { - ModelTurnRecorder.Turn turn = new ModelTurnRecorder.Turn(RUN_ID, "summary-turn"); - commitRequest(turn, request(List.of(ChatMessage.system("summary, first attempt"), - ChatMessage.user("question")))); - commitResponse(turn, toolCallingResponse("call-1"), TOOLS); - - // After a restart the same hook produced a different summary for the same turn. - List rebuilt = List.of(ChatMessage.system("summary, second attempt"), - ChatMessage.user("question")); - - assertThatThrownBy(() -> recorder.replay(new ModelTurnRecorder.Turn(RUN_ID, "summary-turn"), - request(rebuilt), snapshot)) - .isInstanceOf(ModelTurnJournalException.class) - .hasMessageContaining("summarisation"); + assertThat(tools.get()).isEqualTo(1); } - private void commitResponse(ModelTurnRecorder.Turn turn, ChatResponse response, Iterable tools) { - commit(checkpoint(turn), recorder.prepareResponse(turn, response, tools)); + /** In-process reference ordering; production recovery and tool receipts remain runtime concerns. */ + private ChatResponse executeReference(Supplier model, Consumer dispatch) { + return executeReference(ModelTurnRecorder.DEFAULT_MAX_ATTEMPTS, model, dispatch); } - private static RunPayload checkpoint(ModelTurnRecorder.Turn turn) { - return new RunPayload(1, "test.checkpoint", - "{\"phase\":\"TOOLS_PENDING\",\"turnId\":\"" + turn.turnId() + "\"}"); - } - - /** In-process recovery reference only; tool receipts and a production runtime are separate. */ - private ChatResponse executeReference(ModelTurnRecorder.Turn turn, ChatRequest request, - Supplier model, Consumer dispatch) { - ChatResponse response = recorder.replay(turn, request, snapshot).orElseGet(() -> { - commitRequest(turn, request); + private ChatResponse executeReference(int maxAttempts, Supplier model, Consumer dispatch) { + ChatResponse response = recorder.replay(TURN, REQUEST, snapshot).orElseGet(() -> { + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, maxAttempts)); ChatResponse result = model.get(); - commitResponse(turn, result, request.tools()); + commit(checkpoint(), recorder.prepareResponse(TURN, result, REQUEST.tools())); return result; }); response.toolCalls().forEach(dispatch); return response; } - private void commitRequest(ModelTurnRecorder.Turn turn, ChatRequest request) { - commit(null, recorder.prepareRequest(turn, request)); - } - private void commit(RunPayload checkpoint, List entries) { snapshot = runtimeStore.commit(run.namespace(), RUN_ID, lease, new RunCommit(snapshot.revision(), snapshot.state(), checkpoint, List.of(), entries, List.of())); } - private Optional find(String id) { - return Optional.ofNullable(snapshot.journal().get(id)); + private void reload() { + snapshot = store.find(run.namespace(), RUN_ID).orElseThrow(); + recorder = new ModelTurnRecorder(); } - private List completeEntries(ModelTurnRecorder.Turn turn) { - List entries = new ArrayList<>(recorder.prepareRequest(turn, request(FIRST_TURN))); - entries.addAll(recorder.prepareResponse(turn, toolCallingResponse("call-1"), TOOLS)); + private List completeEntries() { + List entries = new ArrayList<>(recorder.prepareRequest(TURN, REQUEST)); + entries.addAll(recorder.prepareResponse(TURN, RESPONSE, TOOLS)); return entries; } @@ -776,8 +461,15 @@ private RunSnapshot snapshotWithJournal(List entries) { snapshot.tasks(), journal, snapshot.outbox()); } - private static ChatRequest request(List messages) { - return new ChatRequest(messages, TOOLS, null, Map.of()); + private static RunPayload checkpoint() { + return new RunPayload(1, "test.checkpoint", "{\"phase\":\"TOOLS_PENDING\",\"turnId\":\"agent:0\"}"); + } + + private static Supplier countedModel(AtomicInteger calls) { + return () -> { + calls.incrementAndGet(); + return RESPONSE; + }; } private static ChatResponse toolCallingResponse(String callId) { @@ -786,24 +478,9 @@ private static ChatResponse toolCallingResponse(String callId) { List.of(call), FinishReason.TOOL_CALLS, null, Map.of()); } - /** Simulates both definite rejection and a write whose acknowledgement is lost. */ - private static final class FailingRunStore implements RunStore { - - private final RunStore delegate; - - private final String payloadType; - - private final boolean commitBeforeFailure; - - private final RuntimeException failure; - - private FailingRunStore(RunStore delegate, String payloadType, boolean commitBeforeFailure, - RuntimeException failure) { - this.delegate = delegate; - this.payloadType = payloadType; - this.commitBeforeFailure = commitBeforeFailure; - this.failure = failure; - } + /** Simulates definite rejection or a successful write whose acknowledgement is lost. */ + private record FailingRunStore(RunStore delegate, String payloadType, boolean commitBeforeFailure, + RuntimeException failure) implements RunStore { @Override public RunStoreCapabilities capabilities() { @@ -850,7 +527,6 @@ public RunSnapshot commit(RunNamespace namespace, String runId, RunLease lease, } return delegate.commit(namespace, runId, lease, commit); } - } } From fd09f574acf21d365bdb1712a374f3afc1482151 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Fri, 30 Oct 2026 14:26:28 +0800 Subject: [PATCH 08/12] feat(agent): expose model-turn journal consumption contracts --- .../ai/agent/durable/ModelTurnCodec.java | 44 ++++- .../ai/agent/durable/ModelTurnRecorder.java | 25 ++- .../ai/agent/durable/PlannedToolCall.java | 43 +++++ .../ai/agent/durable/RecordedModelTurn.java | 39 +++++ .../ai/agent/durable/ToolTaskIdentity.java | 42 +++++ .../ai/agent/durable/package-info.java | 7 + .../agent/durable/ModelTurnPlanViewTest.java | 155 ++++++++++++++++++ .../agent/durable/ModelTurnRecorderTest.java | 65 ++++++++ 8 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/PlannedToolCall.java create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/RecordedModelTurn.java create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java create mode 100644 argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java index cbb32f5bd..51acd4357 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java @@ -22,6 +22,7 @@ import io.github.agentic.ai.api.chat.GenerationOptions; import io.github.agentic.ai.api.chat.ToolCall; import io.github.agentic.ai.api.chat.ToolDefinition; +import io.github.agentic.ai.runtime.execution.run.RunEntry; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.StreamReadFeature; @@ -179,8 +180,8 @@ public static String encodeDelta(String text) { *

    * Arguments are stored and hashed in canonical form, so two semantically equal * invocations that differ only in whitespace or object-key order produce the same - * record and the same hash. The hash is intended to become an idempotency key for - * external effects, which only holds if formatting cannot change it. + * record and the same hash. The hash checks input consistency; the logical task is + * identified separately by {@link ToolTaskIdentity}. */ public static String encodeToolPlan(ToolCall call, ToolDefinition definition, int ordinal) { String arguments = canonicalArguments(call.arguments()); @@ -188,6 +189,45 @@ public static String encodeToolPlan(ToolCall call, ToolDefinition definition, in sha256(arguments), arguments)); } + /** + * Reads an existing plan without resolving tools or validating argument schemas. + * Checks the envelope, stored field types, task identity and argument hash only. + * The caller supplies the owning turn because entries are scoped to a run by storage, + * not by their payload. This method neither verifies a complete model turn nor grants + * permission to dispatch; use the recorder to validate the full committed snapshot. + * @param turn the owning logical model call + * @param entry the stored TASK_CALL entry + * @return the typed view of the stored plan, including a possibly absent definition + * @throws ModelTurnJournalException if the journal entry is incompatible or inconsistent + */ + public static PlannedToolCall decodeToolPlan(ModelTurnRecorder.Turn turn, RunEntry entry) { + Objects.requireNonNull(turn, "turn cannot be null"); + Objects.requireNonNull(entry, "entry cannot be null"); + if (entry.schemaVersion() != 1 || entry.kind() != RunEntry.Kind.TASK_CALL + || entry.payload().schemaVersion() != SCHEMA_VERSION || !PLAN_TYPE.equals(entry.payload().type())) { + throw new ModelTurnJournalException("Incompatible tool plan entry " + entry.id()); + } + JsonNode plan = read(entry.payload().json(), JsonNode.class); + JsonNode ordinal = plan.get("ordinal"); + JsonNode name = plan.get("toolName"); + JsonNode definition = plan.get("definitionVersion"); + JsonNode hash = plan.get("argumentsHash"); + JsonNode arguments = plan.get("arguments"); + if (ordinal == null || !ordinal.isInt() || ordinal.intValue() < 0 + || name == null || !name.isTextual() || name.textValue().isBlank() + || (definition != null && !definition.isNull() && !definition.isTextual()) + || hash == null || !hash.isTextual() || arguments == null || !arguments.isTextual()) { + throw new ModelTurnJournalException("Invalid tool plan fields in entry " + entry.id()); + } + ToolTaskIdentity identity = new ToolTaskIdentity(turn.runId(), turn.turnId(), ordinal.intValue()); + if (!identity.entryId().equals(entry.id()) || !sha256(arguments.textValue()).equals(hash.textValue())) { + throw new ModelTurnJournalException("Inconsistent tool plan identity or argument hash in entry " + entry.id()); + } + return new PlannedToolCall(identity, name.textValue(), + definition == null || definition.isNull() ? null : definition.textValue(), + hash.textValue(), arguments.textValue()); + } + /** * Canonical form of a tool-argument JSON string: object keys sorted recursively, * compact output. diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java index 5067f1944..8af42a3c7 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -94,8 +94,17 @@ public record Turn(String runId, String turnId) { } /** - * Returns the recorded response of this turn when the request is the one that produced - * it. + * Returns only the response from {@link #replayTurn(Turn, ChatRequest, RunSnapshot)}, + * with the same journal consistency checks and no storage side effects. + */ + public Optional replay(Turn turn, ChatRequest request, RunSnapshot snapshot) { + return replayTurn(turn, request, snapshot).map(RecordedModelTurn::response); + } + + /** + * Returns the recorded response and ordered tool plans of this turn when the request + * is the one that produced them. Both initial execution after a successful response + * commit and recovery can consume this same view of the committed snapshot. * *

    * Reads only the supplied snapshot. A response requires its matching request and exactly @@ -118,11 +127,11 @@ public record Turn(String runId, String turnId) { * @param turn the engine-supplied identity of this logical call * @param request the request about to be issued * @param snapshot the runtime's consistent committed view of the run - * @return the recorded response, or empty when the turn has no committed result or plan + * @return the response and plans, or empty when the turn has no committed result or plan * @throws ModelTurnJournalException when recorded data is incomplete, incompatible or * does not match the run or request */ - public Optional replay(Turn turn, ChatRequest request, RunSnapshot snapshot) { + public Optional replayTurn(Turn turn, ChatRequest request, RunSnapshot snapshot) { requireNonNull(turn, "turn cannot be null"); requireNonNull(request, "request cannot be null"); requireNonNull(snapshot, "snapshot cannot be null"); @@ -165,18 +174,20 @@ public Optional replay(Turn turn, ChatRequest request, RunSnapshot if (plans.size() != expected.size()) { throw new ModelTurnJournalException("Model turn " + turn.turnId() + " has an incomplete or extra tool plan"); } + List decodedPlans = new ArrayList<>(); for (RunEntry planned : expected) { RunEntry actual = plans.get(planned.id()); if (actual == null) { throw new ModelTurnJournalException("Missing tool plan entry " + planned.id()); } - validateEntry(actual, planned.id(), RunEntry.Kind.TASK_CALL, ModelTurnCodec.PLAN_TYPE); + PlannedToolCall decodedPlan = ModelTurnCodec.decodeToolPlan(turn, actual); if (!actual.equals(planned)) { throw new ModelTurnJournalException("Recorded tool plan does not match the response and request: " + planned.id()); } + decodedPlans.add(decodedPlan); } - return Optional.of(decoded); + return Optional.of(new RecordedModelTurn(decoded, decodedPlans)); } /** @@ -314,7 +325,7 @@ private List preparePlans(Turn turn, ChatResponse response, Iterable + * The wire field {@code definitionVersion} maps to {@code definitionFingerprint} here. + * For protocol correlation, use the identity's ordinal to read the provider call id + * from the corresponding response's {@code toolCalls()} list. + */ +public record PlannedToolCall(ToolTaskIdentity identity, String toolName, String definitionFingerprint, + String argumentsHash, String arguments) { + + public PlannedToolCall { + requireNonNull(identity, "identity cannot be null"); + requireNonNull(toolName, "toolName cannot be null"); + requireNonNull(argumentsHash, "argumentsHash cannot be null"); + requireNonNull(arguments, "arguments cannot be null"); + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/RecordedModelTurn.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/RecordedModelTurn.java new file mode 100644 index 000000000..549ef3fde --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/RecordedModelTurn.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025-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.durable; + +import io.github.agentic.ai.api.chat.ChatResponse; + +import java.util.Comparator; +import java.util.List; + +import static java.util.Objects.requireNonNull; + +/** + * A recorded response and an immutable view of its plans in ordinal order. The recorder + * validates their journal consistency on replay; constructing this data view does not + * establish storage commitment, current ownership or permission to execute a tool. + */ +public record RecordedModelTurn(ChatResponse response, List plans) { + + public RecordedModelTurn { + requireNonNull(response, "response cannot be null"); + plans = List.copyOf(plans).stream() + .sorted(Comparator.comparingInt(plan -> plan.identity().ordinal())) + .toList(); + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java new file mode 100644 index 000000000..159f518d6 --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025-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.durable; + +/** + * Stable logical tool-task identity within the run's namespace. The caller owns turn + * allocation and recovery. Attempts, provider call ids and argument hashes are not part + * of this identity; callers using it outside a run must retain its RunNamespace as well. + * @param runId the owning run + * @param turnId the caller-supplied logical model call + * @param ordinal the zero-based position in the response's tool-call list + */ +public record ToolTaskIdentity(String runId, String turnId, int ordinal) { + + public ToolTaskIdentity { + if (runId == null || runId.isBlank() || turnId == null || turnId.isBlank()) { + throw new IllegalArgumentException("runId and turnId must not be blank"); + } + if (ordinal < 0) { + throw new IllegalArgumentException("ordinal must be non-negative"); + } + } + + /** Journal key local to the owning run, not a globally scoped idempotency key. */ + public String entryId() { + return turnId + "#task." + ordinal; + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java index 3700f1e6c..a502fefae 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/package-info.java @@ -23,6 +23,13 @@ * every planned tool call. Preparation is stateless and has no storage * side effects. The runtime persists the data before dispatching tools; recovery then * validates and replays a completed turn's recorded result instead of asking the model again. + * {@link io.github.agentic.ai.agent.durable.RecordedModelTurn} exposes that response and + * its ordered {@link io.github.agentic.ai.agent.durable.PlannedToolCall} views. Each plan + * has a {@link io.github.agentic.ai.agent.durable.ToolTaskIdentity}; its argument hash + * checks input consistency and is not a substitute for the logical task identity. + * These views preserve the existing journal format. Reading a plan does not resolve + * its tool, validate its argument schema or authorize dispatch; an unknown tool or + * malformed argument string can still be recorded and read. * *

    * The contract deliberately knows no graph, session, scheduler or streaming library: it diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java new file mode 100644 index 000000000..6249606a4 --- /dev/null +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java @@ -0,0 +1,155 @@ +/* + * Copyright 2025-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.durable; + +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.FinishReason; +import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.runtime.execution.run.RunEntry; +import io.github.agentic.ai.runtime.execution.run.RunPayload; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ModelTurnPlanViewTest { + + private static final ModelTurnRecorder.Turn TURN = new ModelTurnRecorder.Turn("run", "agent:0"); + + private static final String EMPTY_ARGUMENTS_HASH = + "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"; + + /** Version-1 wire fixture, independent of the current writer. */ + private static final String PLAN_JSON = """ + {"ordinal":0,"toolName":"unknown","definitionVersion":null, + "argumentsHash":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","arguments":"{}"} + """; + + @Test + void readsTheExistingWireFormatWithoutRewritingIt() { + RunEntry entry = planEntry(PLAN_JSON); + PlannedToolCall plan = ModelTurnCodec.decodeToolPlan(TURN, entry); + + assertThat(plan).isEqualTo(new PlannedToolCall(new ToolTaskIdentity("run", "agent:0", 0), + "unknown", null, EMPTY_ARGUMENTS_HASH, "{}")); + assertThat(plan.identity().entryId()).isEqualTo(entry.id()); + assertThat(planEntry(ModelTurnCodec.encodeToolPlan(new ToolCall("provider", "unknown", "{}"), null, 0))) + .isEqualTo(entry); + RunEntry withDefinition = planEntry(PLAN_JSON.replace("\"definitionVersion\":null", + "\"definitionVersion\":\"schema-fingerprint\"")); + assertThat(ModelTurnCodec.decodeToolPlan(TURN, withDefinition).definitionFingerprint()) + .isEqualTo("schema-fingerprint"); + } + + @Test + void readsUnknownToolsAndMalformedArgumentsWithoutBusinessValidation() { + String arguments = "{ not valid JSON"; + RunEntry entry = planEntry(ModelTurnCodec.encodeToolPlan( + new ToolCall("provider", "not-disclosed", arguments), null, 0)); + + PlannedToolCall plan = ModelTurnCodec.decodeToolPlan(TURN, entry); + + assertThat(plan.toolName()).isEqualTo("not-disclosed"); + assertThat(plan.definitionFingerprint()).isNull(); + assertThat(plan.arguments()).isEqualTo(arguments); + } + + @Test + void rejectsIncompatibleEnvelopesAndEntryIdentities() { + RunEntry entry = planEntry(PLAN_JSON); + List incompatible = List.of( + new RunEntry(2, entry.id(), entry.kind(), entry.payload()), + new RunEntry(1, entry.id(), RunEntry.Kind.MODEL_CALL, entry.payload()), + new RunEntry(1, entry.id(), entry.kind(), new RunPayload(2, ModelTurnCodec.PLAN_TYPE, PLAN_JSON)), + new RunEntry(1, entry.id(), entry.kind(), new RunPayload(1, "other.type", PLAN_JSON)), + new RunEntry(1, "agent:0#task.1", entry.kind(), entry.payload()), + new RunEntry(1, "other#task.0", entry.kind(), entry.payload())); + + for (RunEntry invalid : incompatible) { + assertThatThrownBy(() -> ModelTurnCodec.decodeToolPlan(TURN, invalid)) + .isInstanceOf(ModelTurnJournalException.class); + } + } + + @Test + void rejectsInvalidStoredFieldsAndInconsistentHashes() { + List invalidPayloads = List.of("{}", + PLAN_JSON.replace("\"ordinal\":0", "\"ordinal\":-1"), + PLAN_JSON.replace("\"ordinal\":0", "\"ordinal\":\"0\""), + PLAN_JSON.replace("\"ordinal\":0", "\"ordinal\":0.5"), + PLAN_JSON.replace("\"ordinal\":0", "\"ordinal\":2147483648"), + PLAN_JSON.replace("\"toolName\":\"unknown\"", "\"toolName\":null"), + PLAN_JSON.replace("\"definitionVersion\":null", "\"definitionVersion\":{}"), + PLAN_JSON.replace("\"arguments\":\"{}\"", "\"arguments\":{}"), + PLAN_JSON.replace(EMPTY_ARGUMENTS_HASH, "different")); + + for (String invalid : invalidPayloads) { + assertThatThrownBy(() -> ModelTurnCodec.decodeToolPlan(TURN, planEntry(invalid))) + .isInstanceOf(ModelTurnJournalException.class); + } + } + + @Test + void identitiesRemainRunScopedAndPreserveTurnIdsWithDelimiters() { + ToolTaskIdentity first = new ToolTaskIdentity("run", "parent#task.0", 0); + assertThat(first.entryId()).isEqualTo("parent#task.0#task.0"); + assertThat(first).isNotEqualTo(new ToolTaskIdentity("other-run", first.turnId(), 0)); + assertThat(first).isNotEqualTo(new ToolTaskIdentity("run", first.turnId(), 1)); + assertThatThrownBy(() -> new ToolTaskIdentity("run", "turn", -1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @ParameterizedTest + @ValueSource(strings = { "", " " }) + void rejectsBlankIdentityComponents(String blank) { + assertThatThrownBy(() -> new ToolTaskIdentity(blank, "turn", 0)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ToolTaskIdentity("run", blank, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void recordedTurnCopiesAndOrdersPlansWithoutChangingTheSourceList() { + PlannedToolCall first = ModelTurnCodec.decodeToolPlan(TURN, planEntry(PLAN_JSON)); + PlannedToolCall second = new PlannedToolCall(new ToolTaskIdentity("run", "agent:0", 1), + first.toolName(), null, first.argumentsHash(), first.arguments()); + List source = new ArrayList<>(List.of(second, first)); + ChatResponse response = new ChatResponse("response", "model", List.of(ChatMessage.assistant("done")), + List.of(), FinishReason.STOP, null, Map.of()); + + RecordedModelTurn recorded = new RecordedModelTurn(response, source); + + assertThat(source).containsExactly(second, first); + source.clear(); + assertThat(recorded.plans()).containsExactly(first, second); + assertThat(recorded.response()).isSameAs(response); + assertThatThrownBy(() -> recorded.plans().clear()).isInstanceOf(UnsupportedOperationException.class); + } + + private static RunEntry planEntry(String json) { + return new RunEntry(1, "agent:0#task.0", RunEntry.Kind.TASK_CALL, + new RunPayload(1, ModelTurnCodec.PLAN_TYPE, json)); + } + +} diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index 8ddb5c5c9..b0ef01408 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -386,6 +386,70 @@ void theSameTaskWithDifferentArgumentsConflictsWithoutUpdatingTheCheckpoint() { assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } + @Test + void replayTurnExposesTheSameCommittedPlansOnInitialReadAndRecoveryWithoutWriting() { + assertThat(recorder.replayTurn(TURN, REQUEST, snapshot)).isEmpty(); + List calls = List.of(new ToolCall("provider-first", "search", "{\"q\":\"same\"}"), + new ToolCall("provider-second", "search", "{\"q\":\"same\"}")); + ChatResponse response = new ChatResponse("response", "model", List.of(ChatMessage.assistant("", calls)), + calls, FinishReason.TOOL_CALLS, null, Map.of()); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + assertThat(recorder.replayTurn(TURN, REQUEST, snapshot)).isEmpty(); + commit(checkpoint(), recorder.prepareResponse(TURN, response, TOOLS)); + RunSnapshot before = snapshot; + + RecordedModelTurn initial = recorder.replayTurn(TURN, REQUEST, snapshot).orElseThrow(); + reload(); + RecordedModelTurn recovered = recorder.replayTurn(TURN, REQUEST, snapshot).orElseThrow(); + + assertThat(recovered).isEqualTo(initial); + assertThat(recorder.replay(TURN, REQUEST, snapshot)).contains(recovered.response()); + assertThat(recovered.plans()).extracting(PlannedToolCall::identity).containsExactly( + new ToolTaskIdentity(RUN_ID, TURN.turnId(), 0), new ToolTaskIdentity(RUN_ID, TURN.turnId(), 1)); + assertThat(recovered.plans().get(0).argumentsHash()).isEqualTo(recovered.plans().get(1).argumentsHash()); + for (PlannedToolCall plan : recovered.plans()) { + assertThat(plan.arguments()).isEqualTo("{\"q\":\"same\"}"); + assertThat(plan.definitionFingerprint()).isEqualTo(ModelTurnCodec.definitionVersion(TOOLS.get(0))); + assertThat(ModelTurnCodec.decodeToolPlan(TURN, snapshot.journal().get(plan.identity().entryId()))) + .isEqualTo(plan); + assertThat(recovered.response().toolCalls().get(plan.identity().ordinal())) + .isEqualTo(calls.get(plan.identity().ordinal())); + } + assertThat(store.find(run.namespace(), RUN_ID)).contains(before); + assertThatThrownBy(() -> recovered.plans().clear()).isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void providerCallIdsAndAttemptReservationsDoNotChangePlanIdentity() { + List firstEntries = recorder.prepareResponse(TURN, toolCallingResponse("provider-one"), TOOLS); + PlannedToolCall first = ModelTurnCodec.decodeToolPlan(TURN, firstEntries.get(1)); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + reload(); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + commit(checkpoint(), recorder.prepareResponse(TURN, toolCallingResponse("provider-two"), TOOLS)); + + RecordedModelTurn recorded = recorder.replayTurn(TURN, REQUEST, snapshot).orElseThrow(); + + assertThat(recorded.plans()).containsExactly(first); + assertThat(recorded.response().toolCalls().get(0).id()).isEqualTo("provider-two"); + } + + @Test + void replayTurnRecordsUnknownToolsAndMalformedArgumentsWithoutAnExecutionGate() { + ToolCall call = new ToolCall("provider", "not-disclosed", "{ invalid JSON"); + ChatResponse response = new ChatResponse("response", "model", + List.of(ChatMessage.assistant("", List.of(call))), List.of(call), FinishReason.TOOL_CALLS, null, Map.of()); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + commit(checkpoint(), recorder.prepareResponse(TURN, response, REQUEST.tools())); + + RecordedModelTurn recorded = recorder.replayTurn(TURN, REQUEST, snapshot).orElseThrow(); + + assertThat(recorded.plans()).hasSize(1); + assertThat(recorded.plans().get(0).toolName()).isEqualTo(call.name()); + assertThat(recorded.plans().get(0).arguments()).isEqualTo(call.arguments()); + assertThat(recorded.plans().get(0).definitionFingerprint()).isNull(); + } + @Test void textOnlyResultsReplayWithoutToolPlans() { ChatResponse text = new ChatResponse("response", "model", List.of(ChatMessage.assistant("done")), @@ -401,6 +465,7 @@ void textOnlyResultsReplayWithoutToolPlans() { assertThat(executeReference(model, call -> tools.incrementAndGet())).isEqualTo(text); assertThat(modelCalls.get()).isEqualTo(1); assertThat(tools.get()).isZero(); + assertThat(recorder.replayTurn(TURN, REQUEST, snapshot).orElseThrow().plans()).isEmpty(); } @Test From afca8546b947b8190fdc7961563e8ee64af69309 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Fri, 30 Oct 2026 14:34:55 +0800 Subject: [PATCH 09/12] refactor(agent): centralize model-turn journal entry IDs --- .../ai/agent/durable/ModelTurnEntryIds.java | 113 ++++++++++++++++++ .../ai/agent/durable/ModelTurnRecorder.java | 59 ++++----- .../ai/agent/durable/ToolTaskIdentity.java | 2 +- .../agent/durable/ModelTurnEntryIdsTest.java | 93 ++++++++++++++ .../agent/durable/ModelTurnRecorderTest.java | 38 ++++++ 5 files changed, 268 insertions(+), 37 deletions(-) create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIds.java create mode 100644 argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIdsTest.java diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIds.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIds.java new file mode 100644 index 000000000..2cfe1b03f --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIds.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025-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.durable; + +import io.github.agentic.ai.agent.durable.ModelTurnRecorder.Turn; + +import java.util.OptionalInt; + +import static java.util.Objects.requireNonNull; + +/** + * Canonical journal entry ids of one model turn. This is the single definition of their + * wire format. Entry ids are local to a run; they do not encode its namespace or run id. + * Payload versions, journal consistency and execution policy remain separate concerns. + */ +public final class ModelTurnEntryIds { + + private static final String PLAN_PREFIX = "#task."; + + private static final String ATTEMPT_PREFIX = "#attempt."; + + private ModelTurnEntryIds() { + } + + /** The effective request fingerprint. */ + public static String request(Turn turn) { + return turn.turnId() + "#req"; + } + + /** The complete terminal response. */ + public static String response(Turn turn) { + return turn.turnId() + "#resp"; + } + + /** A model-call reservation, numbered from one. */ + public static String attempt(Turn turn, int ordinal) { + if (ordinal < 1) { + throw new IllegalArgumentException("attempt ordinal must be positive"); + } + return turn.turnId() + ATTEMPT_PREFIX + ordinal; + } + + /** A planned tool call, numbered from zero in response order. */ + public static String plan(Turn turn, int ordinal) { + if (ordinal < 0) { + throw new IllegalArgumentException("plan ordinal must be non-negative"); + } + return turn.turnId() + PLAN_PREFIX + ordinal; + } + + /** Diagnostic text within a stream attempt; the token is not a call reservation. */ + public static String delta(Turn turn, String streamAttempt, int seq) { + return turn.turnId() + "#progress." + streamAttempt + "#delta." + seq; + } + + /** + * Reads a plan ordinal of this turn, or empty for an unrelated entry. Matching digit + * suffixes with leading zeroes or values outside the int range are corrupt journal ids + * and throw rather than disappearing from replay's complete-plan check. + */ + public static OptionalInt planOrdinal(Turn turn, String entryId) { + return ordinal(turn, entryId, PLAN_PREFIX, 0); + } + + /** + * Reads a reservation ordinal of this turn, or empty for an unrelated entry. Matching + * digit suffixes must be canonical positive ints; invalid ones throw so a corrupt + * reservation cannot silently disappear from the attempt budget. + */ + public static OptionalInt attemptOrdinal(Turn turn, String entryId) { + return ordinal(turn, entryId, ATTEMPT_PREFIX, 1); + } + + private static OptionalInt ordinal(Turn turn, String entryId, String suffix, int minimum) { + requireNonNull(entryId, "entryId cannot be null"); + String prefix = turn.turnId() + suffix; + if (!entryId.startsWith(prefix)) { + return OptionalInt.empty(); + } + String digits = entryId.substring(prefix.length()); + // Turn ids may contain these delimiters: another turn's entry must not leak in. + if (digits.isEmpty() || !digits.chars().allMatch(c -> c >= '0' && c <= '9')) { + return OptionalInt.empty(); + } + if (digits.length() > 1 && digits.charAt(0) == '0') { + throw new ModelTurnJournalException("Non-canonical journal entry ordinal: " + entryId); + } + try { + int value = Integer.parseInt(digits); + if (value < minimum) { + throw new ModelTurnJournalException("Invalid journal entry ordinal: " + entryId); + } + return OptionalInt.of(value); + } + catch (NumberFormatException ex) { + throw new ModelTurnJournalException("Journal entry ordinal exceeds the int range: " + entryId, ex); + } + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java index 8af42a3c7..205bc6995 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -31,6 +31,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Predicate; import static java.util.Objects.requireNonNull; @@ -46,7 +47,7 @@ * they are not model failures. * *

    - * Entry ids of one turn: + * Entry ids are generated and recognized by {@link ModelTurnEntryIds}: *

      *
    • {@code #req} - the request fingerprint
    • *
    • {@code #attempt.} - a model-call reservation, starting at 1
    • @@ -69,10 +70,6 @@ public final class ModelTurnRecorder { private static final int ENTRY_SCHEMA_VERSION = 1; - private static final String PLAN_PREFIX = "#task."; - - private static final String ATTEMPT_PREFIX = "#attempt."; - /** Creates a stateless recorder; the runtime decides whether to use journaling. */ public ModelTurnRecorder() { } @@ -138,17 +135,19 @@ public Optional replayTurn(Turn turn, ChatRequest request, Ru if (!turn.runId().equals(snapshot.spec().runId())) { throw new ModelTurnJournalException("Run snapshot does not match model turn " + turn.turnId()); } - RunEntry recorded = snapshot.journal().get(turn.turnId() + "#req"); - RunEntry response = snapshot.journal().get(turn.turnId() + "#resp"); - Map plans = recordedEntries(turn, snapshot, PLAN_PREFIX); - Map attempts = recordedEntries(turn, snapshot, ATTEMPT_PREFIX); + RunEntry recorded = snapshot.journal().get(ModelTurnEntryIds.request(turn)); + RunEntry response = snapshot.journal().get(ModelTurnEntryIds.response(turn)); + Map plans = recordedEntries(snapshot, + id -> ModelTurnEntryIds.planOrdinal(turn, id).isPresent()); + Map attempts = recordedEntries(snapshot, + id -> ModelTurnEntryIds.attemptOrdinal(turn, id).isPresent()); if (recorded == null && response == null && plans.isEmpty() && attempts.isEmpty()) { return Optional.empty(); } if (recorded == null) { throw new ModelTurnJournalException("Model turn " + turn.turnId() + " is missing its request fingerprint"); } - validateEntry(recorded, turn.turnId() + "#req", RunEntry.Kind.MODEL_CALL, ModelTurnCodec.REQUEST_TYPE); + validateEntry(recorded, ModelTurnEntryIds.request(turn), RunEntry.Kind.MODEL_CALL, ModelTurnCodec.REQUEST_TYPE); String hash = ModelTurnCodec.decodeRequestHash(recorded.payload().json()); if (hash == null || hash.isBlank()) { throw new ModelTurnJournalException("Model turn " + turn.turnId() + " has an invalid request fingerprint"); @@ -168,7 +167,7 @@ public Optional replayTurn(Turn turn, ChatRequest request, Ru } return Optional.empty(); } - validateEntry(response, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, ModelTurnCodec.RESPONSE_TYPE); + validateEntry(response, ModelTurnEntryIds.response(turn), RunEntry.Kind.MODEL_CALL, ModelTurnCodec.RESPONSE_TYPE); ChatResponse decoded = ModelTurnCodec.decodeResponse(response.payload().json()); List expected = preparePlans(turn, decoded, request.tools()); if (plans.size() != expected.size()) { @@ -196,7 +195,7 @@ public Optional replayTurn(Turn turn, ChatRequest request, Ru * {@link #prepareAttempt(Turn, ChatRequest, RunSnapshot)} before calling the provider. */ public List prepareRequest(Turn turn, ChatRequest request) { - return List.of(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + "#req", + return List.of(new RunEntry(ENTRY_SCHEMA_VERSION, ModelTurnEntryIds.request(turn), RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.REQUEST_TYPE, ModelTurnCodec.encodeRequest(request)))); } @@ -234,8 +233,9 @@ public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot throw new ModelTurnJournalException("Model turn " + turn.turnId() + " already has a recorded response"); } String hash = ModelTurnCodec.requestHash(request); - boolean hasRequest = snapshot.journal().containsKey(turn.turnId() + "#req"); - int used = hasRequest ? reservedAttempts(turn, recordedEntries(turn, snapshot, ATTEMPT_PREFIX), hash) : 0; + boolean hasRequest = snapshot.journal().containsKey(ModelTurnEntryIds.request(turn)); + int used = hasRequest ? reservedAttempts(turn, + recordedEntries(snapshot, id -> ModelTurnEntryIds.attemptOrdinal(turn, id).isPresent()), hash) : 0; if (used >= maxAttempts) { throw new ModelTurnJournalException( "Model-call budget exhausted for turn " + turn.turnId() + " (maxAttempts=" + maxAttempts + ")"); @@ -245,7 +245,7 @@ public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot entries.addAll(prepareRequest(turn, request)); } int ordinal = used + 1; - entries.add(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + ATTEMPT_PREFIX + ordinal, + entries.add(new RunEntry(ENTRY_SCHEMA_VERSION, ModelTurnEntryIds.attempt(turn, ordinal), RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.ATTEMPT_TYPE, ModelTurnCodec.encodeAttempt(ordinal, hash)))); return List.copyOf(entries); @@ -264,7 +264,7 @@ RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTur */ public List prepareResponse(Turn turn, ChatResponse response, Iterable tools) { List entries = new ArrayList<>(); - entries.add(new RunEntry(ENTRY_SCHEMA_VERSION, turn.turnId() + "#resp", RunEntry.Kind.MODEL_CALL, + entries.add(new RunEntry(ENTRY_SCHEMA_VERSION, ModelTurnEntryIds.response(turn), RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.RESPONSE_TYPE, ModelTurnCodec.encodeResponse(response)))); entries.addAll(preparePlans(turn, response, tools)); @@ -293,7 +293,7 @@ public List prepareDelta(Turn turn, String attempt, int seq, String te return List.of(); } return List.of(new RunEntry(ENTRY_SCHEMA_VERSION, - turn.turnId() + "#progress." + attempt + "#delta." + seq, RunEntry.Kind.EVENT, + ModelTurnEntryIds.delta(turn, attempt, seq), RunEntry.Kind.EVENT, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTurnCodec.DELTA_TYPE, ModelTurnCodec.encodeDelta(text)))); } @@ -315,7 +315,7 @@ private List preparePlans(Turn turn, ChatResponse response, Iterable preparePlans(Turn turn, ChatResponse response, Iterable recordedEntries(Turn turn, RunSnapshot snapshot, String entryPrefix) { - String prefix = turn.turnId() + entryPrefix; + /** Matching entries keyed by id; extra ordinals remain visible to validation. */ + private Map recordedEntries(RunSnapshot snapshot, Predicate matches) { Map entries = new HashMap<>(); snapshot.journal().forEach((id, entry) -> { - if (id.startsWith(prefix) && isOrdinal(id.substring(prefix.length()))) { + if (matches.test(id)) { entries.put(id, entry); } }); @@ -346,21 +337,17 @@ private Map recordedEntries(Turn turn, RunSnapshot snapshot, S private int reservedAttempts(Turn turn, Map attempts, String requestHash) { // A request written before attempt journaling still reserves its first invocation. - int used = attempts.size() + (attempts.containsKey(turn.turnId() + ATTEMPT_PREFIX + 1) ? 0 : 1); + int used = attempts.size() + (attempts.containsKey(ModelTurnEntryIds.attempt(turn, 1)) ? 0 : 1); attempts.forEach((id, entry) -> { validateEntry(entry, id, RunEntry.Kind.MODEL_CALL, ModelTurnCodec.ATTEMPT_TYPE); int ordinal = ModelTurnCodec.decodeAttemptOrdinal(entry.payload().json(), requestHash); - if (ordinal > used || !id.equals(turn.turnId() + ATTEMPT_PREFIX + ordinal)) { + if (ordinal > used || !id.equals(ModelTurnEntryIds.attempt(turn, ordinal))) { throw new ModelTurnJournalException("Invalid or non-contiguous model attempt entry " + id); } }); return used; } - private static boolean isOrdinal(String suffix) { - return !suffix.isEmpty() && suffix.chars().allMatch(c -> c >= '0' && c <= '9'); - } - private void validateEntry(RunEntry entry, String id, RunEntry.Kind kind, String type) { if (!id.equals(entry.id()) || entry.schemaVersion() != ENTRY_SCHEMA_VERSION || entry.kind() != kind || !type.equals(entry.payload().type()) diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java index 159f518d6..f6fcd23ae 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ToolTaskIdentity.java @@ -36,7 +36,7 @@ public record ToolTaskIdentity(String runId, String turnId, int ordinal) { /** Journal key local to the owning run, not a globally scoped idempotency key. */ public String entryId() { - return turnId + "#task." + ordinal; + return ModelTurnEntryIds.plan(new ModelTurnRecorder.Turn(runId, turnId), ordinal); } } diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIdsTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIdsTest.java new file mode 100644 index 000000000..3f50eac34 --- /dev/null +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnEntryIdsTest.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025-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.durable; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.OptionalInt; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ModelTurnEntryIdsTest { + + private static final ModelTurnRecorder.Turn TURN = new ModelTurnRecorder.Turn("run", "agent:0"); + + @Test + void preservesTheExistingWireIdsAndTaskIdentity() { + assertThat(ModelTurnEntryIds.request(TURN)).isEqualTo("agent:0#req"); + assertThat(ModelTurnEntryIds.response(TURN)).isEqualTo("agent:0#resp"); + assertThat(ModelTurnEntryIds.attempt(TURN, 1)).isEqualTo("agent:0#attempt.1"); + assertThat(ModelTurnEntryIds.plan(TURN, 0)).isEqualTo("agent:0#task.0"); + assertThat(ModelTurnEntryIds.delta(TURN, "stream-token", 2)) + .isEqualTo("agent:0#progress.stream-token#delta.2"); + assertThat(new ToolTaskIdentity(TURN.runId(), TURN.turnId(), 0).entryId()) + .isEqualTo(ModelTurnEntryIds.plan(TURN, 0)); + } + + @ParameterizedTest + @ValueSource(ints = { 1, 2, 10, Integer.MAX_VALUE }) + void numberedIdsRoundTripWithoutOverflow(int ordinal) { + assertThat(ModelTurnEntryIds.planOrdinal(TURN, ModelTurnEntryIds.plan(TURN, ordinal))) + .isEqualTo(OptionalInt.of(ordinal)); + assertThat(ModelTurnEntryIds.attemptOrdinal(TURN, ModelTurnEntryIds.attempt(TURN, ordinal))) + .isEqualTo(OptionalInt.of(ordinal)); + } + + @Test + void plansStartAtZeroAndReservationsStartAtOne() { + assertThat(ModelTurnEntryIds.planOrdinal(TURN, "agent:0#task.0")).isEqualTo(OptionalInt.of(0)); + assertThatThrownBy(() -> ModelTurnEntryIds.plan(TURN, -1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ModelTurnEntryIds.attempt(TURN, 0)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ModelTurnEntryIds.attemptOrdinal(TURN, "agent:0#attempt.0")) + .isInstanceOf(ModelTurnJournalException.class); + } + + @Test + void nestedTurnIdsDoNotLeakIntoTheirParent() { + ModelTurnRecorder.Turn nested = new ModelTurnRecorder.Turn("run", "agent:0#task.0#attempt.1"); + String plan = ModelTurnEntryIds.plan(nested, 2); + String attempt = ModelTurnEntryIds.attempt(nested, 3); + + assertThat(ModelTurnEntryIds.planOrdinal(nested, plan)).isEqualTo(OptionalInt.of(2)); + assertThat(ModelTurnEntryIds.attemptOrdinal(nested, attempt)).isEqualTo(OptionalInt.of(3)); + assertThat(ModelTurnEntryIds.planOrdinal(TURN, plan)).isEqualTo(OptionalInt.empty()); + assertThat(ModelTurnEntryIds.attemptOrdinal(TURN, attempt)).isEqualTo(OptionalInt.empty()); + assertThat(ModelTurnEntryIds.planOrdinal(TURN, ModelTurnEntryIds.request(nested))) + .isEqualTo(OptionalInt.empty()); + } + + @ParameterizedTest + @ValueSource(strings = { "other#task.0", "agent:00#task.0", "agent:0#req", "agent:0#resp", + "agent:0#task.", "agent:0#task.x", "agent:0#task.1#resp", "agent:0#attempt.", + "agent:0#attempt.x", "agent:0#attempt.1#req" }) + void unrelatedEntriesAreNotNumberedEntriesOfThisTurn(String entryId) { + assertThat(ModelTurnEntryIds.planOrdinal(TURN, entryId)).isEqualTo(OptionalInt.empty()); + assertThat(ModelTurnEntryIds.attemptOrdinal(TURN, entryId)).isEqualTo(OptionalInt.empty()); + } + + @ParameterizedTest + @ValueSource(strings = { "00", "01", "2147483648", "9999999999999999999999999" }) + void corruptNumericSuffixesCannotBeSilentlyDropped(String suffix) { + assertThatThrownBy(() -> ModelTurnEntryIds.planOrdinal(TURN, "agent:0#task." + suffix)) + .isInstanceOf(ModelTurnJournalException.class); + assertThatThrownBy(() -> ModelTurnEntryIds.attemptOrdinal(TURN, "agent:0#attempt." + suffix)) + .isInstanceOf(ModelTurnJournalException.class); + } + +} diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index b0ef01408..b8d4aebb9 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -386,6 +386,44 @@ void theSameTaskWithDifferentArgumentsConflictsWithoutUpdatingTheCheckpoint() { assertThat(store.find(run.namespace(), RUN_ID)).contains(before); } + @ParameterizedTest + @ValueSource(strings = { "0", "01", "2147483648" }) + void malformedReservationIdsRemainVisibleToBudgetValidation(String suffix) { + RunEntry request = recorder.prepareRequest(TURN, REQUEST).get(0); + RunEntry attempt = new RunEntry(1, TURN.turnId() + "#attempt." + suffix, RunEntry.Kind.MODEL_CALL, + new RunPayload(1, ModelTurnCodec.ATTEMPT_TYPE, + ModelTurnCodec.encodeAttempt(1, ModelTurnCodec.requestHash(REQUEST)))); + RunSnapshot recorded = snapshotWithJournal(List.of(request, attempt)); + + assertThatThrownBy(() -> recorder.prepareAttempt(TURN, REQUEST, recorded, 5)) + .isInstanceOf(ModelTurnJournalException.class); + } + + @ParameterizedTest + @ValueSource(strings = { "00", "2147483648" }) + void malformedExtraPlanIdsRemainVisibleToReplay(String suffix) { + List entries = completeEntries(); + RunEntry plan = entries.get(2); + entries.add(new RunEntry(1, TURN.turnId() + "#task." + suffix, plan.kind(), plan.payload())); + RunSnapshot recorded = snapshotWithJournal(entries); + + assertThatThrownBy(() -> recorder.replayTurn(TURN, REQUEST, recorded)) + .isInstanceOf(ModelTurnJournalException.class); + } + + @Test + void nestedTurnsDoNotAffectReplayOrReservationCounts() { + ModelTurnRecorder.Turn nested = new ModelTurnRecorder.Turn(RUN_ID, TURN.turnId() + "#attempt.1#task.0"); + commit(null, recorder.prepareAttempt(nested, REQUEST, snapshot)); + commit(checkpoint(), recorder.prepareResponse(nested, RESPONSE, TOOLS)); + + assertThat(recorder.replayTurn(TURN, REQUEST, snapshot)).isEmpty(); + assertThat(recorder.prepareAttempt(TURN, REQUEST, snapshot, 1)).extracting(RunEntry::id) + .containsExactly("agent:0#req", "agent:0#attempt.1"); + assertThat(recorder.replayTurn(nested, REQUEST, snapshot).orElseThrow().plans().get(0).identity()) + .isEqualTo(new ToolTaskIdentity(RUN_ID, nested.turnId(), 0)); + } + @Test void replayTurnExposesTheSameCommittedPlansOnInitialReadAndRecoveryWithoutWriting() { assertThat(recorder.replayTurn(TURN, REQUEST, snapshot)).isEmpty(); From 4a503b3b808de0c75378b05e3a23d65aec679fba Mon Sep 17 00:00:00 2001 From: GMJneko Date: Fri, 30 Oct 2026 15:16:12 +0800 Subject: [PATCH 10/12] feat(agent): add explicit model-turn re-ask policy --- .../agent/durable/MissingResponsePolicy.java | 27 ++++++++ .../agent/durable/ModelTurnAttemptPolicy.java | 36 +++++++++++ .../ai/agent/durable/ModelTurnRecorder.java | 60 ++++++++++++------ .../agent/durable/ModelTurnRecorderTest.java | 63 +++++++++++++++---- 4 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/MissingResponsePolicy.java create mode 100644 argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnAttemptPolicy.java diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/MissingResponsePolicy.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/MissingResponsePolicy.java new file mode 100644 index 000000000..fd67d1f85 --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/MissingResponsePolicy.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025-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.durable; + +/** Controls whether a model turn with a reserved call but no recorded response may be retried. */ +public enum MissingResponsePolicy { + + /** Stop recovery when the outcome of a reserved model call is unknown. */ + FAIL_CLOSED, + + /** Permit another model call while the total attempt limit has room. */ + REASK + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnAttemptPolicy.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnAttemptPolicy.java new file mode 100644 index 000000000..0faeedc76 --- /dev/null +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnAttemptPolicy.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025-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.durable; + +import static java.util.Objects.requireNonNull; + +/** Immutable recovery policy and total model-call limit for one logical turn. */ +public record ModelTurnAttemptPolicy(MissingResponsePolicy missingResponsePolicy, int maxAttempts) { + + public static final int DEFAULT_MAX_ATTEMPTS = 1; + + /** The default permits the initial call and fails closed on recovery. */ + public static final ModelTurnAttemptPolicy DEFAULT = + new ModelTurnAttemptPolicy(MissingResponsePolicy.FAIL_CLOSED, DEFAULT_MAX_ATTEMPTS); + + public ModelTurnAttemptPolicy { + requireNonNull(missingResponsePolicy, "missingResponsePolicy cannot be null"); + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be positive"); + } + } + +} diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java index 205bc6995..870b3e67f 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnRecorder.java @@ -57,16 +57,18 @@ *
    * *

    - * A matching request without a response or plan may be re-asked within its per-turn attempt - * limit. By default two calls are allowed in total, including the first. Every invocation - * requires a committed reservation from {@link #prepareAttempt(Turn, ChatRequest, RunSnapshot)}; - * restarting never resets the count. Even a reservation whose call was not issued consumes - * an attempt. Run-wide, token and cost budgets remain runtime responsibilities. + * A matching request without a response or plan is re-asked only when its + * {@link ModelTurnAttemptPolicy} uses {@link MissingResponsePolicy#REASK}, and within its + * per-turn attempt limit. By default recovery fails closed and the total limit is one call. + * Every invocation requires a committed reservation from + * {@link #prepareAttempt(Turn, ChatRequest, RunSnapshot)}; restarting never resets the count. + * Even a reservation whose call was not issued consumes an attempt. Run-wide, token and cost + * budgets remain runtime responsibilities. */ public final class ModelTurnRecorder { /** Total model-call reservations per turn, including the first invocation. */ - public static final int DEFAULT_MAX_ATTEMPTS = 2; + public static final int DEFAULT_MAX_ATTEMPTS = ModelTurnAttemptPolicy.DEFAULT_MAX_ATTEMPTS; private static final int ENTRY_SCHEMA_VERSION = 1; @@ -200,9 +202,26 @@ RunEntry.Kind.MODEL_CALL, new RunPayload(ModelTurnCodec.SCHEMA_VERSION, ModelTur ModelTurnCodec.encodeRequest(request)))); } - /** Prepares a model-call reservation using {@link #DEFAULT_MAX_ATTEMPTS}. */ + /** Prepares a model-call reservation using {@link ModelTurnAttemptPolicy#DEFAULT}. */ public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot snapshot) { - return prepareAttempt(turn, request, snapshot, DEFAULT_MAX_ATTEMPTS); + return prepareAttempt(turn, request, snapshot, ModelTurnAttemptPolicy.DEFAULT); + } + + /** + * Prepares the next model-call reservation using the default fail-closed recovery policy + * and the supplied total attempt limit. + * @param turn the logical model call + * @param request the effective provider request + * @param snapshot the committed view used for replay and the reservation commit + * @param maxAttempts positive total invocation limit + * @return immutable entries to commit before at most one provider invocation + * @throws ModelTurnJournalException if the turn already has a result, its data is + * incompatible, recovery re-asking is disabled, or its model-call budget is exhausted + * @throws IllegalArgumentException if maxAttempts is not positive + */ + public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot snapshot, int maxAttempts) { + return prepareAttempt(turn, request, snapshot, + new ModelTurnAttemptPolicy(MissingResponsePolicy.FAIL_CLOSED, maxAttempts)); } /** @@ -215,20 +234,21 @@ public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot * The limit includes the first call. A legacy request without attempt records counts as * one reservation. Reservations are never refunded for a lost response or an interrupted * invocation; changing the limit does not reset the stored count. Streaming delta tokens - * are unrelated to this count. This limit does not replace runtime-owned run budgets. + * are unrelated to this count. {@link MissingResponsePolicy#FAIL_CLOSED} rejects another + * reservation after an earlier call is reserved without a response. + * {@link MissingResponsePolicy#REASK} permits it while the total limit has room. This limit + * does not replace runtime-owned run budgets. * @param turn the logical model call * @param request the effective provider request * @param snapshot the committed view used for replay and the reservation commit - * @param maxAttempts positive total invocation limit; 1 disables recovery re-asking + * @param policy recovery policy and positive total invocation limit * @return immutable entries to commit before at most one provider invocation * @throws ModelTurnJournalException if the turn already has a result, its data is - * incompatible, or its model-call budget is exhausted - * @throws IllegalArgumentException if maxAttempts is not positive + * incompatible, recovery re-asking is disabled, or its model-call budget is exhausted */ - public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot snapshot, int maxAttempts) { - if (maxAttempts < 1) { - throw new IllegalArgumentException("maxAttempts must be positive"); - } + public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot snapshot, + ModelTurnAttemptPolicy policy) { + requireNonNull(policy, "policy cannot be null"); if (replay(turn, request, snapshot).isPresent()) { throw new ModelTurnJournalException("Model turn " + turn.turnId() + " already has a recorded response"); } @@ -236,9 +256,13 @@ public List prepareAttempt(Turn turn, ChatRequest request, RunSnapshot boolean hasRequest = snapshot.journal().containsKey(ModelTurnEntryIds.request(turn)); int used = hasRequest ? reservedAttempts(turn, recordedEntries(snapshot, id -> ModelTurnEntryIds.attemptOrdinal(turn, id).isPresent()), hash) : 0; - if (used >= maxAttempts) { + if (used > 0 && policy.missingResponsePolicy() == MissingResponsePolicy.FAIL_CLOSED) { + throw new ModelTurnJournalException("Recovery re-asking is disabled for model turn " + turn.turnId()); + } + if (used >= policy.maxAttempts()) { throw new ModelTurnJournalException( - "Model-call budget exhausted for turn " + turn.turnId() + " (maxAttempts=" + maxAttempts + ")"); + "Model-call budget exhausted for turn " + turn.turnId() + + " (maxAttempts=" + policy.maxAttempts() + ")"); } List entries = new ArrayList<>(); if (!hasRequest) { diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index b8d4aebb9..6e031de24 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -76,6 +76,9 @@ class ModelTurnRecorderTest { private static final ChatResponse RESPONSE = toolCallingResponse("call-1"); + private static final ModelTurnAttemptPolicy REASK_TWO_ATTEMPTS = + new ModelTurnAttemptPolicy(MissingResponsePolicy.REASK, 2); + private final MemoryRunStore store = new MemoryRunStore(); private RunStore runtimeStore = store; @@ -187,7 +190,7 @@ void lostAttemptConfirmationDoesNotInvokeTheModelOrRefundTheReservation() { reload(); assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#attempt.1"); assertThatThrownBy(() -> executeReference(1, countedModel(modelCalls), call -> tools.incrementAndGet())) - .isInstanceOf(ModelTurnJournalException.class).hasMessageContaining("budget exhausted"); + .isInstanceOf(ModelTurnJournalException.class).hasMessageContaining("re-asking is disabled"); assertThat(modelCalls.get()).isZero(); assertThat(tools.get()).isZero(); } @@ -239,15 +242,16 @@ void aStaleSnapshotCannotAuthorizeAnotherModelCall() { @ParameterizedTest @ValueSource(ints = { 1, 2, 3 }) void committedReservationsSurviveReloadsAndEnforceTheConfiguredLimit(int maxAttempts) { + ModelTurnAttemptPolicy policy = new ModelTurnAttemptPolicy(MissingResponsePolicy.REASK, maxAttempts); for (int ordinal = 1; ordinal <= maxAttempts; ordinal++) { - commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, maxAttempts)); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, policy)); reload(); assertThat(snapshot.journal()).containsKey("agent:0#attempt." + ordinal); } AtomicInteger modelCalls = new AtomicInteger(); AtomicInteger tools = new AtomicInteger(); - assertThatThrownBy(() -> executeReference(maxAttempts, countedModel(modelCalls), + assertThatThrownBy(() -> executeReference(policy, countedModel(modelCalls), call -> tools.incrementAndGet())).isInstanceOf(ModelTurnJournalException.class) .hasMessageContaining("budget exhausted"); @@ -256,7 +260,7 @@ void committedReservationsSurviveReloadsAndEnforceTheConfiguredLimit(int maxAtte } @Test - void aLostResponseCanBeReaskedOnceByDefaultAfterReload() { + void aLostResponseFailsClosedByDefaultAfterReload() { IllegalStateException unavailable = new IllegalStateException("response storage unavailable"); runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, false, unavailable); AtomicInteger modelCalls = new AtomicInteger(); @@ -268,7 +272,29 @@ void aLostResponseCanBeReaskedOnceByDefaultAfterReload() { runtimeStore = store; reload(); - ChatResponse recovered = executeReference(model, call -> tools.incrementAndGet()); + assertThatThrownBy(() -> executeReference(model, call -> tools.incrementAndGet())) + .isInstanceOf(ModelTurnJournalException.class).hasMessageContaining("re-asking is disabled"); + + assertThat(modelCalls.get()).isEqualTo(1); + assertThat(tools.get()).isZero(); + assertThat(snapshot.journal()).containsOnlyKeys("agent:0#req", "agent:0#attempt.1"); + } + + @Test + void aLostResponseCanBeReaskedWhenExplicitlyEnabled() { + IllegalStateException unavailable = new IllegalStateException("response storage unavailable"); + runtimeStore = new FailingRunStore(store, ModelTurnCodec.RESPONSE_TYPE, false, unavailable); + AtomicInteger modelCalls = new AtomicInteger(); + AtomicInteger tools = new AtomicInteger(); + Supplier model = () -> toolCallingResponse("provider-call-" + modelCalls.incrementAndGet()); + ModelTurnAttemptPolicy policy = REASK_TWO_ATTEMPTS; + + assertThatThrownBy(() -> executeReference(policy, model, call -> tools.incrementAndGet())).isSameAs(unavailable); + assertThat(tools.get()).isZero(); + + runtimeStore = store; + reload(); + ChatResponse recovered = executeReference(policy, model, call -> tools.incrementAndGet()); assertThat(modelCalls.get()).isEqualTo(2); assertThat(tools.get()).isEqualTo(1); @@ -279,16 +305,25 @@ void aLostResponseCanBeReaskedOnceByDefaultAfterReload() { @Test void aLegacyRequestCountsAsTheFirstReservation() { commit(null, recorder.prepareRequest(TURN, REQUEST)); - List recovery = recorder.prepareAttempt(TURN, REQUEST, snapshot); + ModelTurnAttemptPolicy policy = REASK_TWO_ATTEMPTS; + List recovery = recorder.prepareAttempt(TURN, REQUEST, snapshot, policy); assertThat(recovery).extracting(RunEntry::id).containsExactly("agent:0#attempt.2"); commit(null, recovery); reload(); - assertThatThrownBy(() -> recorder.prepareAttempt(TURN, REQUEST, snapshot)) + assertThatThrownBy(() -> recorder.prepareAttempt(TURN, REQUEST, snapshot, policy)) .isInstanceOf(ModelTurnJournalException.class).hasMessageContaining("budget exhausted"); } + @Test + void attemptLimitAloneDoesNotEnableRecoveryReasking() { + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + + assertThatThrownBy(() -> recorder.prepareAttempt(TURN, REQUEST, snapshot, 5)) + .isInstanceOf(ModelTurnJournalException.class).hasMessageContaining("re-asking is disabled"); + } + @Test void differentTurnsHaveIndependentAttemptBudgets() { commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, 1)); @@ -461,9 +496,9 @@ void replayTurnExposesTheSameCommittedPlansOnInitialReadAndRecoveryWithoutWritin void providerCallIdsAndAttemptReservationsDoNotChangePlanIdentity() { List firstEntries = recorder.prepareResponse(TURN, toolCallingResponse("provider-one"), TOOLS); PlannedToolCall first = ModelTurnCodec.decodeToolPlan(TURN, firstEntries.get(1)); - commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, REASK_TWO_ATTEMPTS)); reload(); - commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, REASK_TWO_ATTEMPTS)); commit(checkpoint(), recorder.prepareResponse(TURN, toolCallingResponse("provider-two"), TOOLS)); RecordedModelTurn recorded = recorder.replayTurn(TURN, REQUEST, snapshot).orElseThrow(); @@ -527,12 +562,18 @@ void diagnosticDeltasRetainTheCheckpointUntilTheTerminalResponse() { /** In-process reference ordering; production recovery and tool receipts remain runtime concerns. */ private ChatResponse executeReference(Supplier model, Consumer dispatch) { - return executeReference(ModelTurnRecorder.DEFAULT_MAX_ATTEMPTS, model, dispatch); + return executeReference(ModelTurnAttemptPolicy.DEFAULT, model, dispatch); } private ChatResponse executeReference(int maxAttempts, Supplier model, Consumer dispatch) { + return executeReference(new ModelTurnAttemptPolicy(MissingResponsePolicy.FAIL_CLOSED, maxAttempts), model, + dispatch); + } + + private ChatResponse executeReference(ModelTurnAttemptPolicy policy, Supplier model, + Consumer dispatch) { ChatResponse response = recorder.replay(TURN, REQUEST, snapshot).orElseGet(() -> { - commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, maxAttempts)); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot, policy)); ChatResponse result = model.get(); commit(checkpoint(), recorder.prepareResponse(TURN, result, REQUEST.tools())); return result; From 6fb62dc0fa6a9f52d300b46702e445159eae1a11 Mon Sep 17 00:00:00 2001 From: GMJneko Date: Fri, 30 Oct 2026 16:52:36 +0800 Subject: [PATCH 11/12] test(agent): fix model-turn plan compatibility fixture --- .../github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java index 6249606a4..04e31144e 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnPlanViewTest.java @@ -76,10 +76,9 @@ void readsUnknownToolsAndMalformedArgumentsWithoutBusinessValidation() { } @Test - void rejectsIncompatibleEnvelopesAndEntryIdentities() { + void rejectsIncompatibleKindsPayloadsAndEntryIdentities() { RunEntry entry = planEntry(PLAN_JSON); List incompatible = List.of( - new RunEntry(2, entry.id(), entry.kind(), entry.payload()), new RunEntry(1, entry.id(), RunEntry.Kind.MODEL_CALL, entry.payload()), new RunEntry(1, entry.id(), entry.kind(), new RunPayload(2, ModelTurnCodec.PLAN_TYPE, PLAN_JSON)), new RunEntry(1, entry.id(), entry.kind(), new RunPayload(1, "other.type", PLAN_JSON)), From 191ec90bfcd9c7a34a344719b56f2f60313acfaa Mon Sep 17 00:00:00 2001 From: GMJneko Date: Tue, 3 Nov 2026 17:08:58 +0800 Subject: [PATCH 12/12] fix(agent): fix model-turn codec for high precision decimal handling --- .../ai/agent/durable/ModelTurnCodec.java | 6 ++- .../agent/durable/ModelTurnRecorderTest.java | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java index 51acd4357..69d3bbeaa 100644 --- a/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java +++ b/argi-agent/argi-agent-core/src/main/java/io/github/agentic/ai/agent/durable/ModelTurnCodec.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectReader; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -111,6 +112,9 @@ public final class ModelTurnCodec { .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) .build(); + private static final ObjectReader ARGUMENT_READER = MAPPER.readerFor(JsonNode.class) + .with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS); + private ModelTurnCodec() { } @@ -246,7 +250,7 @@ public static String canonicalArguments(String arguments) { return arguments; } try { - JsonNode tree = MAPPER.readTree(arguments); + JsonNode tree = ARGUMENT_READER.readTree(arguments); return tree == null || !tree.isContainerNode() ? arguments : write(sortKeys(tree)); } catch (JsonProcessingException malformed) { diff --git a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java index 6e031de24..76a0bec92 100644 --- a/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java +++ b/argi-agent/argi-agent-core/src/test/java/io/github/agentic/ai/agent/durable/ModelTurnRecorderTest.java @@ -134,6 +134,52 @@ void toolPlansIgnoreFormattingAndProviderCallIdsWhilePreservingArrayOrder() { .isEqualTo("{\"tags\":[\"b\",\"a\"]}"); } + @Test + void canonicalArgumentsPreserveNestedDecimalValuesAndArrayOrder() { + String arguments = "{\"z\":[0.12345678901234567891,{\"b\":0.12345678901234567890,\"a\":1}],\"a\":true}"; + String expected = "{\"a\":true,\"z\":[0.12345678901234567891,{\"a\":1,\"b\":0.1234567890123456789}]}"; + String equivalent = "{ \"a\" : true, \"z\" : [0.123456789012345678910," + + "{\"a\":1,\"b\":1.2345678901234567890e-1}] }"; + + assertThat(ModelTurnCodec.canonicalArguments(arguments)).isEqualTo(expected); + assertThat(ModelTurnCodec.canonicalArguments(equivalent)).isEqualTo(expected); + assertThat(ModelTurnCodec.canonicalArguments(expected)).isEqualTo(expected); + } + + @Test + void highPrecisionToolArgumentsProduceDistinctPlansAndHashesAfterRecovery() { + List calls = List.of(new ToolCall("first", "search", "{\"value\":0.12345678901234567890}"), + new ToolCall("second", "search", "{\"value\":0.12345678901234567891}")); + assertThat(ModelTurnCodec.encodeToolPlan(calls.get(0), TOOLS.get(0), 0)) + .isNotEqualTo(ModelTurnCodec.encodeToolPlan(calls.get(1), TOOLS.get(0), 0)); + ChatResponse response = new ChatResponse("response", "model", List.of(ChatMessage.assistant("", calls)), + calls, FinishReason.TOOL_CALLS, null, Map.of()); + commit(null, recorder.prepareAttempt(TURN, REQUEST, snapshot)); + commit(checkpoint(), recorder.prepareResponse(TURN, response, TOOLS)); + reload(); + + RecordedModelTurn recovered = recorder.replayTurn(TURN, REQUEST, snapshot).orElseThrow(); + + assertThat(recovered.plans()).extracting(PlannedToolCall::arguments).containsExactly( + "{\"value\":0.1234567890123456789}", "{\"value\":0.12345678901234567891}"); + assertThat(recovered.plans().get(0).argumentsHash()).isNotEqualTo(recovered.plans().get(1).argumentsHash()); + assertThat(recovered.response().messages().get(0).toolCalls()).extracting(ToolCall::arguments) + .containsExactly("{\"value\":0.1234567890123456789}", "{\"value\":0.12345678901234567891}"); + assertThat(recovered.response().toolCalls()).isEqualTo(calls); + } + + @Test + void requestFingerprintsDistinguishHighPrecisionToolArguments() { + ChatRequest first = new ChatRequest(List.of(ChatMessage.assistant("", + List.of(new ToolCall("call", "search", "{\"value\":0.12345678901234567890}")))), + TOOLS, null, Map.of()); + ChatRequest second = new ChatRequest(List.of(ChatMessage.assistant("", + List.of(new ToolCall("call", "search", "{\"value\":0.12345678901234567891}")))), + TOOLS, null, Map.of()); + + assertThat(ModelTurnCodec.requestHash(first)).isNotEqualTo(ModelTurnCodec.requestHash(second)); + } + @Test void referenceDispatchesOnlyAfterTheResponseAllPlansAndCheckpointAreCommitted() { List calls = List.of(new ToolCall("first", "search", "{\"q\":\"first\"}"),