From 34252f0c439643dbcb33d9b3d538b2dc6aaaaced Mon Sep 17 00:00:00 2001 From: konlue <2362586639@qq.com> Date: Mon, 2 Nov 2026 17:42:46 +0800 Subject: [PATCH 1/5] feat(agent): define typed lifecycle extension point contracts (issue #298) --- .../ai/agent/lifecycle/AdmissionGate.java | 57 ++ .../ai/agent/lifecycle/AroundChain.java | 36 ++ .../ai/agent/lifecycle/AroundInterceptor.java | 61 ++ .../agentic/ai/agent/lifecycle/AttemptId.java | 48 ++ .../ai/agent/lifecycle/DelegationId.java | 49 ++ .../ai/agent/lifecycle/GateDecision.java | 122 ++++ .../ai/agent/lifecycle/InvocationId.java | 48 ++ .../lifecycle/LifecycleCapabilities.java | 68 +++ .../ai/agent/lifecycle/LifecycleEvent.java | 261 +++++++++ .../agent/lifecycle/LifecycleExtension.java | 59 ++ .../ai/agent/lifecycle/LifecycleIdentity.java | 153 +++++ .../lifecycle/LifecycleMutationGuard.java | 84 +++ .../ai/agent/lifecycle/LifecycleObserver.java | 52 ++ .../ai/agent/lifecycle/LifecycleStage.java | 167 ++++++ .../agent/lifecycle/LifecycleTransformer.java | 60 ++ .../ai/agent/lifecycle/ModelCallExecutor.java | 35 ++ .../agent/lifecycle/ModelCallInvocation.java | 49 ++ .../ai/agent/lifecycle/ModelCallOutcome.java | 76 +++ .../ai/agent/lifecycle/ModelTurnId.java | 50 ++ .../agentic/ai/agent/lifecycle/StepId.java | 48 ++ .../ai/agent/lifecycle/ToolCallExecutor.java | 35 ++ .../ai/agent/lifecycle/ToolCallId.java | 48 ++ .../agent/lifecycle/ToolCallInvocation.java | 55 ++ .../ai/agent/lifecycle/ToolCallOutcome.java | 74 +++ .../lifecycle/LifecycleContractTest.java | 539 ++++++++++++++++++ .../lifecycle-extension-points.md | 198 +++++++ 26 files changed, 2532 insertions(+) create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AdmissionGate.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundChain.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundInterceptor.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AttemptId.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/DelegationId.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/GateDecision.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/InvocationId.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleCapabilities.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleEvent.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleExtension.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleIdentity.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleObserver.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleStage.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleTransformer.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallExecutor.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallOutcome.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelTurnId.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/StepId.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallExecutor.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallId.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallInvocation.java create mode 100644 argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallOutcome.java create mode 100644 argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java create mode 100644 docs/architecture/lifecycle-extension-points.md diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AdmissionGate.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AdmissionGate.java new file mode 100644 index 000000000..8f071902b --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AdmissionGate.java @@ -0,0 +1,57 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * An extension that decides whether a stage may run. + * + *

+ * The gate is the only extension kind that may refuse work. A denial short-circuits + * the stage with a reason; an approval request is handed to the approval channel of + * the engine. An engine without an approval channel must degrade an approval request + * to a denial instead of letting the stage through. + * + *

+ * The gate does not rewrite anything. A gate that wants to change a request combines + * with a {@link LifecycleTransformer}; a gate that wants to wrap the stage combines + * with an {@link AroundInterceptor}. + */ +public interface AdmissionGate extends LifecycleExtension { + + /** + * The stage this gate is bound to. + * @return the bound stage, never null + */ + LifecycleStage stage(); + + /** + * Decides whether the stage may run. + * @param event the event of the bound stage, never null + * @return the decision, never null + */ + GateDecision admit(LifecycleEvent event); + + @Override + default java.util.Set stages() { + return LifecycleExtension.boundTo(stage()); + } + + @Override + default LifecycleCapabilities capabilities() { + return LifecycleCapabilities.synchronousOnly(); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundChain.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundChain.java new file mode 100644 index 000000000..5ded18759 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundChain.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * The continuation an around interceptor wraps. + * + *

+ * The chain is the part of the stage that actually performs the work. An interceptor + * may call it zero times to short-circuit the stage, once for the normal path, or more + * than once when the stage is retried. + */ +@FunctionalInterface +public interface AroundChain { + + /** + * Continues the stage. + * @param event the event handed to the stage, never null + * @return the event produced by the stage, never null + */ + LifecycleEvent proceed(LifecycleEvent event); + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundInterceptor.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundInterceptor.java new file mode 100644 index 000000000..97c3f40a2 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AroundInterceptor.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * An extension that wraps a stage and can rewrite, short-circuit or repeat it. + * + *

+ * The around interceptor is the strongest extension kind: it may rewrite the event it + * receives, may return a result without calling the chain at all, and may call the + * chain more than once to implement a retry or a fallback. + * + *

+ * It may not change the trusted identity of the run. The contract does not expose a + * way to rewrite the trusted execution context, and an interceptor that needs a + * stronger identity must ask the run for a new one instead of editing the current one. + * + *

+ * A failing interceptor aborts the stage. The failure is explicit: the engine reports + * it as a {@link LifecycleEvent.ExtensionFailed} event. + */ +public interface AroundInterceptor extends LifecycleExtension { + + /** + * The stage this interceptor is bound to. + * @return the bound stage, never null + */ + LifecycleStage stage(); + + /** + * Wraps the stage. + * @param event the event of the bound stage, never null + * @param chain the continuation of the stage, never null + * @return the event that leaves the stage, never null + */ + LifecycleEvent intercept(LifecycleEvent event, AroundChain chain); + + @Override + default java.util.Set stages() { + return LifecycleExtension.boundTo(stage()); + } + + @Override + default LifecycleCapabilities capabilities() { + return LifecycleCapabilities.synchronousOnly(); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AttemptId.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AttemptId.java new file mode 100644 index 000000000..7f1bc2977 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/AttemptId.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * Identity of one attempt inside an invocation. + * + *

+ * An attempt is a retry of a unit of work. It is distinct from the invocation itself + * and from a step: a retried model call is a new attempt of the same step. + * + * @param value the opaque attempt identifier + */ +public record AttemptId(String value) { + + /** + * Creates an attempt identity. + * @param value the opaque attempt identifier, must not be null + */ + public AttemptId { + Objects.requireNonNull(value, "value must not be null"); + } + + /** + * Creates an attempt identity from its textual form. + * @param value the opaque attempt identifier, must not be null + * @return the attempt identity + */ + public static AttemptId of(String value) { + return new AttemptId(value); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/DelegationId.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/DelegationId.java new file mode 100644 index 000000000..456a0fa8b --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/DelegationId.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * Identity of one delegation inside an invocation. + * + *

+ * A delegation is the hand-over of work to another agent, plus the join of its result. + * It is not a tool call: the delegated agent runs its own invocation with its own + * lifecycle. + * + * @param value the opaque delegation identifier + */ +public record DelegationId(String value) { + + /** + * Creates a delegation identity. + * @param value the opaque delegation identifier, must not be null + */ + public DelegationId { + Objects.requireNonNull(value, "value must not be null"); + } + + /** + * Creates a delegation identity from its textual form. + * @param value the opaque delegation identifier, must not be null + * * @return the delegation identity + */ + public static DelegationId of(String value) { + return new DelegationId(value); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/GateDecision.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/GateDecision.java new file mode 100644 index 000000000..3af674324 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/GateDecision.java @@ -0,0 +1,122 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * The outcome of an admission gate. + * + *

+ * Three outcomes exist, and they are not interchangeable: + * + *

+ * + * @param outcome the gate outcome, never null + * @param reason the reason of a denial or an approval request, nullable + */ +public record GateDecision(Outcome outcome, String reason) { + + /** + * Creates a gate decision. + * @param outcome the gate outcome, must not be null + * @param reason the reason of a denial or an approval request, nullable + */ + public GateDecision { + Objects.requireNonNull(outcome, "outcome must not be null"); + if (outcome != Outcome.ALLOW && (reason == null || reason.isBlank())) { + throw new IllegalArgumentException("a reason is required for " + outcome); + } + } + + /** + * Allows the stage. + * @return an allowing decision + */ + public static GateDecision allow() { + return new GateDecision(Outcome.ALLOW, null); + } + + /** + * Denies the stage with a reason. + * @param reason the denial reason, must not be blank + * @return a denying decision + */ + public static GateDecision deny(String reason) { + return new GateDecision(Outcome.DENY, reason); + } + + /** + * Requests an approval decision for the stage. + * @param reason the approval reason, must not be blank + * @return an approval request + */ + public static GateDecision requireApproval(String reason) { + return new GateDecision(Outcome.REQUIRE_APPROVAL, reason); + } + + /** + * Whether the stage may proceed. + * @return true for an allowing decision + */ + public boolean isAllowed() { + return outcome == Outcome.ALLOW; + } + + /** + * Whether the stage is short-circuited. + * @return true for a denying decision + */ + public boolean isDenied() { + return outcome == Outcome.DENY; + } + + /** + * Whether the stage waits for an approval decision. + * @return true for an approval request + */ + public boolean requiresApproval() { + return outcome == Outcome.REQUIRE_APPROVAL; + } + + /** + * The outcome of an admission gate. + */ + public enum Outcome { + + /** + * The stage proceeds. + */ + ALLOW, + + /** + * The stage is short-circuited. + */ + DENY, + + /** + * The stage needs an approval decision. + */ + REQUIRE_APPROVAL + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/InvocationId.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/InvocationId.java new file mode 100644 index 000000000..56023cd1a --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/InvocationId.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * Identity of one agent invocation. + * + *

+ * An invocation is the outermost unit of work: one call into an agent. Every attempt, + * step, model turn, tool call and delegation belongs to exactly one invocation. + * + * @param value the opaque invocation identifier + */ +public record InvocationId(String value) { + + /** + * Creates an invocation identity. + * @param value the opaque invocation identifier, must not be null + */ + public InvocationId { + Objects.requireNonNull(value, "value must not be null"); + } + + /** + * Creates an invocation identity from its textual form. + * @param value the opaque invocation identifier, must not be null + * @return the invocation identity + */ + public static InvocationId of(String value) { + return new InvocationId(value); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleCapabilities.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleCapabilities.java new file mode 100644 index 000000000..205d703d7 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleCapabilities.java @@ -0,0 +1,68 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * Capability flags declared by a lifecycle extension. + * + *

+ * A capability answers a single question: does the extension support this execution + * mode at its bound stage? The flags are descriptive, never a promise that every engine + * provides every stage. An engine that cannot honour a flag must say so instead of + * silently degrading the extension. + * + * @param synchronous whether the extension runs on the calling thread + * @param asynchronous whether the extension may complete on another thread + * @param cancellable whether the extension observes cancellation of the bound stage + * @param streaming whether the extension participates in a streamed stage + * @param resumable whether the extension survives a suspend and resume of the bound + * stage + */ +public record LifecycleCapabilities(boolean synchronous, boolean asynchronous, boolean cancellable, boolean streaming, + boolean resumable) { + + /** + * No capability at all. + * @return capabilities without any supported execution mode + */ + public static LifecycleCapabilities none() { + return new LifecycleCapabilities(false, false, false, false, false); + } + + /** + * The extension only runs synchronously on the calling thread. + * @return synchronous-only capabilities + */ + public static LifecycleCapabilities synchronousOnly() { + return new LifecycleCapabilities(true, false, false, false, false); + } + + /** + * The extension only completes asynchronously. + * @return asynchronous-only capabilities + */ + public static LifecycleCapabilities asynchronousOnly() { + return new LifecycleCapabilities(false, true, false, false, false); + } + + /** + * Whether the extension supports at least one execution mode. + * @return true when any capability is declared + */ + public boolean supportsAny() { + return synchronous || asynchronous || cancellable || streaming || resumable; + } +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleEvent.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleEvent.java new file mode 100644 index 000000000..41959bc4b --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleEvent.java @@ -0,0 +1,261 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * An immutable notification about one lifecycle stage. + * + *

+ * The event set is closed on purpose: a plugin never receives a free-form payload map + * it has to interpret, and it never receives a mutable object it could corrupt for + * everybody else. Every event carries the stage it belongs to and the identity of the + * work it describes. + * + *

+ * Events are observations, not commands. Mutating an event is not possible; the only + * way to influence a stage is the transformer, the around interceptor or the admission + * gate. + */ +public sealed interface LifecycleEvent permits LifecycleEvent.StageEntered, LifecycleEvent.StageExited, + LifecycleEvent.ModelCallRequested, LifecycleEvent.ModelCallCompleted, LifecycleEvent.ToolCallRequested, + LifecycleEvent.ToolCallCompleted, LifecycleEvent.ToolCallRejected, LifecycleEvent.ExtensionFailed { + + /** + * The stage this event belongs to. + * @return the stage, never null + */ + LifecycleStage stage(); + + /** + * The identity of the work this event describes. + * @return the identity, never null + */ + LifecycleIdentity identity(); + + /** + * A stage was entered. + * + * @param stage the stage, never null + * @param identity the identity of the work, never null + */ + record StageEntered(LifecycleStage stage, LifecycleIdentity identity) implements LifecycleEvent { + + /** + * Creates a stage-entered event. + * @param stage the stage, must not be null + * @param identity the identity of the work, must not be null + */ + public StageEntered { + java.util.Objects.requireNonNull(stage, "stage must not be null"); + java.util.Objects.requireNonNull(identity, "identity must not be null"); + } + } + + /** + * A stage was left. + * + * @param stage the stage, never null + * @param identity the identity of the work, never null + * @param successful whether the stage completed successfully + * @param failureReason the failure description, nullable + */ + record StageExited(LifecycleStage stage, LifecycleIdentity identity, boolean successful, String failureReason) + implements LifecycleEvent { + + /** + * Creates a stage-exited event. + * @param stage the stage, must not be null + * @param identity the identity of the work, must not be null + * @param successful whether the stage completed successfully + * @param failureReason the failure description, nullable + */ + public StageExited { + java.util.Objects.requireNonNull(stage, "stage must not be null"); + java.util.Objects.requireNonNull(identity, "identity must not be null"); + } + } + + /** + * A model call was requested. + * + * @param invocation the model call, never null + */ + record ModelCallRequested(ModelCallInvocation invocation) implements LifecycleEvent { + + /** + * Creates a model-call-requested event. + * @param invocation the model call, must not be null + */ + public ModelCallRequested { + java.util.Objects.requireNonNull(invocation, "invocation must not be null"); + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.MODEL_CALL; + } + + @Override + public LifecycleIdentity identity() { + return invocation.identity(); + } + } + + /** + * A model call was completed. + * + * @param invocation the model call, never null + * @param outcome the outcome of the call, never null + */ + record ModelCallCompleted(ModelCallInvocation invocation, ModelCallOutcome outcome) implements LifecycleEvent { + + /** + * Creates a model-call-completed event. + * @param invocation the model call, must not be null + * @param outcome the outcome of the call, must not be null + */ + public ModelCallCompleted { + java.util.Objects.requireNonNull(invocation, "invocation must not be null"); + java.util.Objects.requireNonNull(outcome, "outcome must not be null"); + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.MODEL_CALL; + } + + @Override + public LifecycleIdentity identity() { + return invocation.identity(); + } + } + + /** + * A tool call was requested. + * + * @param invocation the tool call, never null + */ + record ToolCallRequested(ToolCallInvocation invocation) implements LifecycleEvent { + + /** + * Creates a tool-call-requested event. + * @param invocation the tool call, must not be null + */ + public ToolCallRequested { + java.util.Objects.requireNonNull(invocation, "invocation must not be null"); + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public LifecycleIdentity identity() { + return invocation.identity(); + } + } + + /** + * A tool call was completed. + * + * @param invocation the tool call, never null + * @param outcome the outcome of the call, never null + */ + record ToolCallCompleted(ToolCallInvocation invocation, ToolCallOutcome outcome) implements LifecycleEvent { + + /** + * Creates a tool-call-completed event. + * @param invocation the tool call, must not be null + * @param outcome the outcome of the call, must not be null + */ + public ToolCallCompleted { + java.util.Objects.requireNonNull(invocation, "invocation must not be null"); + java.util.Objects.requireNonNull(outcome, "outcome must not be null"); + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public LifecycleIdentity identity() { + return invocation.identity(); + } + } + + /** + * A tool call was rejected before execution. + * + * @param invocation the rejected tool call, never null + * @param reason the rejection reason, never null + */ + record ToolCallRejected(ToolCallInvocation invocation, String reason) implements LifecycleEvent { + + /** + * Creates a tool-call-rejected event. + * @param invocation the rejected tool call, must not be null + * @param reason the rejection reason, must not be null + */ + public ToolCallRejected { + java.util.Objects.requireNonNull(invocation, "invocation must not be null"); + java.util.Objects.requireNonNull(reason, "reason must not be null"); + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public LifecycleIdentity identity() { + return invocation.identity(); + } + } + + /** + * An extension failed while it was invoked. + * + *

+ * The event is the explicit failure semantics of the contract: an observer failure + * is reported instead of being swallowed, and an around interceptor failure is + * surfaced as an event rather than as a silently degraded stage. + * + * @param stage the stage the extension was bound to, never null + * @param identity the identity of the work, never null + * @param extensionId the identifier of the failing extension, never null + * @param message the failure description, never null + */ + record ExtensionFailed(LifecycleStage stage, LifecycleIdentity identity, String extensionId, String message) + implements LifecycleEvent { + + /** + * Creates an extension-failed event. + * @param stage the stage the extension was bound to, must not be null + * @param identity the identity of the work, must not be null + * @param extensionId the identifier of the failing extension, must not be null + * @param message the failure description, must not be null + */ + public ExtensionFailed { + java.util.Objects.requireNonNull(stage, "stage must not be null"); + java.util.Objects.requireNonNull(identity, "identity must not be null"); + java.util.Objects.requireNonNull(extensionId, "extensionId must not be null"); + java.util.Objects.requireNonNull(message, "message must not be null"); + } + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleExtension.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleExtension.java new file mode 100644 index 000000000..d04ebfe64 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleExtension.java @@ -0,0 +1,59 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; +import java.util.Set; + +/** + * The root contract of a lifecycle extension. + * + *

+ * Every extension declares a stable identifier, the stages it binds to and the + * execution modes it supports. The declaration is what an engine uses to reject an + * extension it cannot honour: an engine that cannot run a bound stage asynchronously + * must say so instead of quietly changing the semantics of the extension. + */ +public interface LifecycleExtension { + + /** + * The stable identifier of the extension. + * @return the extension identifier, never null + */ + String id(); + + /** + * The stages this extension binds to. + * @return the bound stages, never null and never empty + */ + Set stages(); + + /** + * The execution modes this extension supports. + * @return the declared capabilities, never null + */ + LifecycleCapabilities capabilities(); + + /** + * A single-stage binding helper for extensions that bind to exactly one stage. + * @param stage the bound stage, must not be null + * @return the set containing the stage + */ + static Set boundTo(LifecycleStage stage) { + return Set.of(Objects.requireNonNull(stage, "stage must not be null")); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleIdentity.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleIdentity.java new file mode 100644 index 000000000..c117d5d2a --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleIdentity.java @@ -0,0 +1,153 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * The identity of the unit of work an extension is invoked for. + * + *

+ * The invocation identity is always present. The remaining identities are present only + * when the bound stage actually produces them, which keeps the vocabulary explicit + * instead of collapsing everything into one opaque identifier: + * + *

+ * + *

+ * The identity is read-only for extensions: no plugin may rewrite it, and no plugin may + * derive a stronger identity than the one it was given. + * + * @param invocation the invocation identity, never null + * @param attempt the attempt identity, nullable + * @param step the step identity, nullable + * @param modelTurn the model turn identity, nullable + * @param toolCall the tool call identity, nullable + * @param delegation the delegation identity, nullable + */ +public record LifecycleIdentity(InvocationId invocation, AttemptId attempt, StepId step, ModelTurnId modelTurn, + ToolCallId toolCall, DelegationId delegation) { + + /** + * Creates an identity. + * @param invocation the invocation identity, must not be null + * @param attempt the attempt identity, nullable + * @param step the step identity, nullable + * @param modelTurn the model turn identity, nullable + * @param toolCall the tool call identity, nullable + * @param delegation the delegation identity, nullable + */ + public LifecycleIdentity { + Objects.requireNonNull(invocation, "invocation must not be null"); + } + + /** + * Starts a builder for an identity rooted at the given invocation. + * @param invocation the invocation identity, must not be null + * @return a new builder + */ + public static Builder builder(InvocationId invocation) { + return new Builder(invocation); + } + + /** + * Builder for {@link LifecycleIdentity}. + */ + public static final class Builder { + + private final InvocationId invocation; + + private AttemptId attempt; + + private StepId step; + + private ModelTurnId modelTurn; + + private ToolCallId toolCall; + + private DelegationId delegation; + + private Builder(InvocationId invocation) { + this.invocation = Objects.requireNonNull(invocation, "invocation must not be null"); + } + + /** + * Sets the attempt identity. + * @param attempt the attempt identity, nullable + * @return this builder + */ + public Builder attempt(AttemptId attempt) { + this.attempt = attempt; + return this; + } + + /** + * Sets the step identity. + * @param step the step identity, nullable + * @return this builder + */ + public Builder step(StepId step) { + this.step = step; + return this; + } + + /** + * Sets the model turn identity. + * @param modelTurn the model turn identity, nullable + * @return this builder + */ + public Builder modelTurn(ModelTurnId modelTurn) { + this.modelTurn = modelTurn; + return this; + } + + /** + * Sets the tool call identity. + * @param toolCall the tool call identity, nullable + * @return this builder + */ + public Builder toolCall(ToolCallId toolCall) { + this.toolCall = toolCall; + return this; + } + + /** + * Sets the delegation identity. + * @param delegation the delegation identity, nullable + * @return this builder + */ + public Builder delegation(DelegationId delegation) { + this.delegation = delegation; + return this; + } + + /** + * Builds the identity. + * @return the immutable identity + */ + public LifecycleIdentity build() { + return new LifecycleIdentity(invocation, attempt, step, modelTurn, toolCall, delegation); + } + + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java new file mode 100644 index 000000000..4f9b866b5 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Optional; + +/** + * Decides whether a restricted transformation stays inside its boundary. + * + *

+ * The guard is the executable form of the "restricted transformer" rule. It compares + * the original event with the rewritten one and reports the first violation: + * + *

+ * + *

+ * Renaming a tool is deliberately not a transformation: it would silently redirect the + * call to a different binding, which is an admission decision. + */ +public final class LifecycleMutationGuard { + + private LifecycleMutationGuard() { + } + + /** + * Checks a rewritten event against the original one. + * @param original the original event, must not be null + * @param rewritten the rewritten event, must not be null + * @return the violation description, or empty when the rewrite is allowed + */ + public static Optional check(LifecycleEvent original, LifecycleEvent rewritten) { + if (original == null || rewritten == null) { + return Optional.of("event must not be null"); + } + if (original.getClass() != rewritten.getClass()) { + return Optional.of("event type must not change"); + } + if (!original.identity().equals(rewritten.identity())) { + return Optional.of("identity must not change"); + } + if (original instanceof LifecycleEvent.ModelCallRequested before) { + LifecycleEvent.ModelCallRequested after = (LifecycleEvent.ModelCallRequested) rewritten; + if (!before.invocation().modelTurn().equals(after.invocation().modelTurn())) { + return Optional.of("model turn identity must not change"); + } + return Optional.empty(); + } + if (original instanceof LifecycleEvent.ToolCallRequested before) { + LifecycleEvent.ToolCallRequested after = (LifecycleEvent.ToolCallRequested) rewritten; + if (!before.invocation().toolCall().equals(after.invocation().toolCall())) { + return Optional.of("tool call identity must not change"); + } + if (!before.invocation().toolName().equals(after.invocation().toolName())) { + return Optional.of("tool name must not change"); + } + return Optional.empty(); + } + if (!original.equals(rewritten)) { + return Optional.of(original.getClass().getSimpleName() + " does not allow transformation"); + } + return Optional.empty(); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleObserver.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleObserver.java new file mode 100644 index 000000000..475138f3d --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleObserver.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Set; + +/** + * An extension that observes lifecycle events without changing them. + * + *

+ * The observer is the weakest extension kind and therefore the easiest to adopt. It + * cannot rewrite a request, cannot short-circuit a stage and cannot veto anything. + * + *

+ * A failing observer must not break the run. The contract makes the failure explicit + * instead of hiding it: the engine reports it as a + * {@link LifecycleEvent.ExtensionFailed} event and continues. + */ +public interface LifecycleObserver extends LifecycleExtension { + + /** + * Observes one lifecycle event. + * @param event the event, never null + */ + void onEvent(LifecycleEvent event); + + /** + * The stages this observer is bound to. + * @return the bound stages, never null + */ + @Override + Set stages(); + + @Override + default LifecycleCapabilities capabilities() { + return LifecycleCapabilities.synchronousOnly(); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleStage.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleStage.java new file mode 100644 index 000000000..3982e9768 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleStage.java @@ -0,0 +1,167 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * The lifecycle stages an extension may bind to. + * + *

+ * The catalog distinguishes two scopes: + * + *

+ * + *

+ * Two boundaries are deliberate. A workflow step is not a model turn: a workflow engine + * must not fabricate model turns to satisfy the catalog. A remote agent is not an + * in-process tool caller: the neutral contract only exposes the remote agent's own + * public run-level stages, never the internals of its tool execution. + */ +public enum LifecycleStage { + + /** + * Definition validation and creation. + */ + DEFINITION(LifecycleScope.COMMON_RUNTIME, LifecycleCapabilities.synchronousOnly()), + + /** + * Admission of a run before any work is scheduled. + */ + RUN_ADMISSION(LifecycleScope.COMMON_RUNTIME, LifecycleCapabilities.synchronousOnly()), + + /** + * Start of an admitted run. + */ + RUN_START(LifecycleScope.COMMON_RUNTIME, LifecycleCapabilities.synchronousOnly()), + + /** + * Construction of the execution context of a run. + */ + CONTEXT_BUILD(LifecycleScope.COMMON_RUNTIME, LifecycleCapabilities.synchronousOnly()), + + /** + * A single model call. + */ + MODEL_CALL(LifecycleScope.COMMON_RUNTIME, + new LifecycleCapabilities(true, true, true, false, false)), + + /** + * A streamed model call. + */ + MODEL_STREAM(LifecycleScope.COMMON_RUNTIME, + new LifecycleCapabilities(false, true, true, true, false)), + + /** + * The decision of which action to take next. + */ + ACTION_DECISION(LifecycleScope.COMMON_RUNTIME, LifecycleCapabilities.synchronousOnly()), + + /** + * A single tool call. + */ + TOOL_CALL(LifecycleScope.COMMON_RUNTIME, new LifecycleCapabilities(true, true, true, false, false)), + + /** + * Delegation, handover and join of work. + */ + DELEGATION(LifecycleScope.COMMON_RUNTIME, new LifecycleCapabilities(true, true, false, false, false)), + + /** + * A workflow graph step. Engine specific: a workflow engine owns this stage. + */ + WORKFLOW_STEP(LifecycleScope.ENGINE_SPECIFIC, + new LifecycleCapabilities(true, true, true, false, true)), + + /** + * Suspension and resumption of a run. Engine specific: the owning engine defines + * the resume protocol. + */ + SUSPEND_RESUME(LifecycleScope.ENGINE_SPECIFIC, + new LifecycleCapabilities(false, true, false, false, true)), + + /** + * Delivery of the run output to the caller. + */ + OUTPUT_DELIVERY(LifecycleScope.COMMON_RUNTIME, new LifecycleCapabilities(true, true, false, false, false)), + + /** + * Completion, failure, cancellation and release of a run. + */ + TERMINATION(LifecycleScope.COMMON_RUNTIME, LifecycleCapabilities.synchronousOnly()); + + private final LifecycleScope scope; + + private final LifecycleCapabilities capabilities; + + LifecycleStage(LifecycleScope scope, LifecycleCapabilities capabilities) { + this.scope = scope; + this.capabilities = capabilities; + } + + /** + * The scope of this stage. + * @return the stage scope, never null + */ + public LifecycleScope scope() { + return scope; + } + + /** + * The execution modes this stage supports. + * @return the stage capabilities, never null + */ + public LifecycleCapabilities capabilities() { + return capabilities; + } + + /** + * Whether the stage is common to all engines. + * @return true for a common runtime stage + */ + public boolean isCommonRuntime() { + return scope == LifecycleScope.COMMON_RUNTIME; + } + + /** + * Whether the stage belongs to a specific engine family. + * @return true for an engine specific stage + */ + public boolean isEngineSpecific() { + return scope == LifecycleScope.ENGINE_SPECIFIC; + } + + /** + * The scope of a lifecycle stage. + */ + public enum LifecycleScope { + + /** + * A stage every engine is expected to expose. + */ + COMMON_RUNTIME, + + /** + * A stage only a particular engine family can offer. + */ + ENGINE_SPECIFIC + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleTransformer.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleTransformer.java new file mode 100644 index 000000000..32b0044e4 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleTransformer.java @@ -0,0 +1,60 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Optional; + +/** + * An extension that may rewrite a stage in a restricted way. + * + *

+ * The transformer receives the event of its bound stage and returns either a rewritten + * event or {@link Optional#empty()} to keep the original. It cannot short-circuit the + * stage: refusing work is an admission decision and belongs to the + * {@link AdmissionGate}. + * + *

+ * The rewrite is restricted to the fields the event type declares as mutable. + * {@link LifecycleMutationGuard} decides whether a rewrite stays inside that boundary; + * a rewrite that changes the identity, the tool name or the model turn identity is + * rejected. + */ +public interface LifecycleTransformer extends LifecycleExtension { + + /** + * The stage this transformer is bound to. + * @return the bound stage, never null + */ + LifecycleStage stage(); + + /** + * Rewrites the event of the bound stage. + * @param event the event to rewrite, never null + * @return the rewritten event, or empty to keep the original + */ + Optional transform(LifecycleEvent event); + + @Override + default java.util.Set stages() { + return LifecycleExtension.boundTo(stage()); + } + + @Override + default LifecycleCapabilities capabilities() { + return LifecycleCapabilities.synchronousOnly(); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallExecutor.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallExecutor.java new file mode 100644 index 000000000..26950f9ce --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallExecutor.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * Executes a model call in the neutral lifecycle contract. + * + *

+ * The executor is the continuation an around interceptor wraps. It performs the actual + * model call and returns its outcome. + */ +@FunctionalInterface +public interface ModelCallExecutor { + + /** + * Executes the model call. + * @param invocation the model call to execute, never null + * @return the outcome of the call, never null + */ + ModelCallOutcome execute(ModelCallInvocation invocation); + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java new file mode 100644 index 000000000..1e3a5fe7d --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import io.github.agentic.ai.agent.interceptor.ModelRequest; + +import java.util.Objects; + +/** + * A model call in the neutral lifecycle contract. + * + *

+ * The invocation is immutable: a restricted transformer may replace the request, but + * neither the identity nor the model turn identity may change. The request keeps the + * trusted execution context of the run; the neutral contract never exposes a way to + * rewrite that context. + * + * @param identity the identity of the work this call belongs to, never null + * @param modelTurn the model turn identity, never null + * @param request the model request, never null + */ +public record ModelCallInvocation(LifecycleIdentity identity, ModelTurnId modelTurn, ModelRequest request) { + + /** + * Creates a model call invocation. + * @param identity the identity of the work this call belongs to, must not be null + * @param modelTurn the model turn identity, must not be null + * @param request the model request, must not be null + */ + public ModelCallInvocation { + Objects.requireNonNull(identity, "identity must not be null"); + Objects.requireNonNull(modelTurn, "modelTurn must not be null"); + Objects.requireNonNull(request, "request must not be null"); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallOutcome.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallOutcome.java new file mode 100644 index 000000000..a6c0ba1d4 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallOutcome.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import io.github.agentic.ai.agent.interceptor.ModelResponse; + +import java.util.Objects; + +/** + * The result of a model call in the neutral lifecycle contract. + * + *

+ * A model call either succeeds with a response or fails with an error description. + * Exactly one of {@code response} and {@code error} is present. + * + * @param modelTurn the model turn identity, never null + * @param response the model response, nullable + * @param error the failure description, nullable + */ +public record ModelCallOutcome(ModelTurnId modelTurn, ModelResponse response, String error) { + + /** + * Creates a model call outcome. + * @param modelTurn the model turn identity, must not be null + * @param response the model response, nullable + * @param error the failure description, nullable + */ + public ModelCallOutcome { + Objects.requireNonNull(modelTurn, "modelTurn must not be null"); + if ((response == null) == (error == null)) { + throw new IllegalArgumentException("exactly one of response and error must be present"); + } + } + + /** + * Creates a successful outcome. + * @param modelTurn the model turn identity, must not be null + * @param response the model response, must not be null + * @return the successful outcome + */ + public static ModelCallOutcome success(ModelTurnId modelTurn, ModelResponse response) { + return new ModelCallOutcome(modelTurn, response, null); + } + + /** + * Creates a failed outcome. + * @param modelTurn the model turn identity, must not be null + * @param error the failure description, must not be null + * @return the failed outcome + */ + public static ModelCallOutcome failure(ModelTurnId modelTurn, String error) { + return new ModelCallOutcome(modelTurn, null, error); + } + + /** + * Whether the call succeeded. + * @return true when a response is present + */ + public boolean isSuccess() { + return response != null; + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelTurnId.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelTurnId.java new file mode 100644 index 000000000..04b858cbb --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelTurnId.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * Identity of one model turn inside an invocation. + * + *

+ * A model turn is one request to a model and its answer. It is deliberately not the + * message identity: {@code AgentEvent.messageId} identifies a message, while a model + * turn identifies the model interaction that produced it. A workflow step does not + * create a model turn on its own. + * + * @param value the opaque model turn identifier + */ +public record ModelTurnId(String value) { + + /** + * Creates a model turn identity. + * @param value the opaque model turn identifier, must not be null + */ + public ModelTurnId { + Objects.requireNonNull(value, "value must not be null"); + } + + /** + * Creates a model turn identity from its textual form. + * @param value the opaque model turn identifier, must not be null + * @return the model turn identity + */ + public static ModelTurnId of(String value) { + return new ModelTurnId(value); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/StepId.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/StepId.java new file mode 100644 index 000000000..eebb9bca0 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/StepId.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * Identity of one step inside an invocation. + * + *

+ * A step is a unit of work that may contain model turns and tool calls. A step is + * stable across retries of the same work; the attempts below it are not. + * + * @param value the opaque step identifier + */ +public record StepId(String value) { + + /** + * Creates a step identity. + * @param value the opaque step identifier, must not be null + */ + public StepId { + Objects.requireNonNull(value, "value must not be null"); + } + + /** + * Creates a step identity from its textual form. + * @param value the opaque step identifier, must not be null + * @return the step identity + */ + public static StepId of(String value) { + return new StepId(value); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallExecutor.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallExecutor.java new file mode 100644 index 000000000..46ced3cd2 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallExecutor.java @@ -0,0 +1,35 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +/** + * Executes a tool call in the neutral lifecycle contract. + * + *

+ * The executor is the continuation an around interceptor wraps. It performs the actual + * tool execution and returns its outcome. + */ +@FunctionalInterface +public interface ToolCallExecutor { + + /** + * Executes the tool call. + * @param invocation the tool call to execute, never null + * @return the outcome of the call, never null + */ + ToolCallOutcome execute(ToolCallInvocation invocation); + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallId.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallId.java new file mode 100644 index 000000000..3826bf884 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallId.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * Identity of one tool call inside an invocation. + * + *

+ * A tool call is one request to a tool binding and its result. Retries of the same + * tool call keep the same identity and differ in their attempt identity. + * + * @param value the opaque tool call identifier + */ +public record ToolCallId(String value) { + + /** + * Creates a tool call identity. + * @param value the opaque tool call identifier, must not be null + */ + public ToolCallId { + Objects.requireNonNull(value, "value must not be null"); + } + + /** + * Creates a tool call identity from its textual form. + * @param value the opaque tool call identifier, must not be null + * @return the tool call identity + */ + public static ToolCallId of(String value) { + return new ToolCallId(value); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallInvocation.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallInvocation.java new file mode 100644 index 000000000..c8e2db8f8 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallInvocation.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * A tool call in the neutral lifecycle contract. + * + *

+ * The invocation is immutable. A restricted transformer may replace the arguments of + * a tool call, but neither the identity, the tool call identity nor the tool name may + * change: renaming a tool inside a transformation would silently redirect the call to + * a different binding, which is an admission decision, not a transformation. + * + *

+ * The type deliberately carries no free-form context map. Extensions that need + * additional data read it from the run state or the trusted execution context instead + * of an untyped side channel. + * + * @param identity the identity of the work this call belongs to, never null + * @param toolCall the tool call identity, never null + * @param toolName the name of the tool binding, never null + * @param arguments the serialized tool arguments, never null + */ +public record ToolCallInvocation(LifecycleIdentity identity, ToolCallId toolCall, String toolName, String arguments) { + + /** + * Creates a tool call invocation. + * @param identity the identity of the work this call belongs to, must not be null + * @param toolCall the tool call identity, must not be null + * @param toolName the name of the tool binding, must not be null + * @param arguments the serialized tool arguments, must not be null + */ + public ToolCallInvocation { + Objects.requireNonNull(identity, "identity must not be null"); + Objects.requireNonNull(toolCall, "toolCall must not be null"); + Objects.requireNonNull(toolName, "toolName must not be null"); + Objects.requireNonNull(arguments, "arguments must not be null"); + } + +} diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallOutcome.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallOutcome.java new file mode 100644 index 000000000..2b8c2fd59 --- /dev/null +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ToolCallOutcome.java @@ -0,0 +1,74 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import java.util.Objects; + +/** + * The result of a tool call in the neutral lifecycle contract. + * + *

+ * A tool call either succeeds with a result or fails with an error description. + * Exactly one of {@code result} and {@code error} is present. + * + * @param toolCall the tool call identity, never null + * @param result the tool result, nullable + * @param error the failure description, nullable + */ +public record ToolCallOutcome(ToolCallId toolCall, String result, String error) { + + /** + * Creates a tool call outcome. + * @param toolCall the tool call identity, must not be null + * @param result the tool result, nullable + * @param error the failure description, nullable + */ + public ToolCallOutcome { + Objects.requireNonNull(toolCall, "toolCall must not be null"); + if ((result == null) == (error == null)) { + throw new IllegalArgumentException("exactly one of result and error must be present"); + } + } + + /** + * Creates a successful outcome. + * @param toolCall the tool call identity, must not be null + * @param result the tool result, must not be null + * @return the successful outcome + */ + public static ToolCallOutcome success(ToolCallId toolCall, String result) { + return new ToolCallOutcome(toolCall, result, null); + } + + /** + * Creates a failed outcome. + * @param toolCall the tool call identity, must not be null + * @param error the failure description, must not be null + * @return the failed outcome + */ + public static ToolCallOutcome failure(ToolCallId toolCall, String error) { + return new ToolCallOutcome(toolCall, null, error); + } + + /** + * Whether the call succeeded. + * @return true when a result is present + */ + public boolean isSuccess() { + return result != null; + } + +} diff --git a/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java new file mode 100644 index 000000000..96ac1d550 --- /dev/null +++ b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java @@ -0,0 +1,539 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.agentic.ai.agent.lifecycle; + +import io.github.agentic.ai.agent.interceptor.ModelRequest; +import io.github.agentic.ai.agent.interceptor.ModelResponse; +import io.github.agentic.ai.api.chat.ChatMessage; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Contract tests for the neutral lifecycle extension points. + * + *

+ * The tests drive the four extension kinds directly, without a model, a tool binding + * or an engine. The chain executor and the engine wiring are separate issues; what + * this test pins down is the semantics of the contract itself: observation, + * restricted transformation, rejection, wrapping and failure handling. + */ +class LifecycleContractTest { + + private static final InvocationId INVOCATION = InvocationId.of("inv-1"); + + private static final ModelTurnId MODEL_TURN = ModelTurnId.of("turn-1"); + + private static final ToolCallId TOOL_CALL = ToolCallId.of("call-1"); + + private static final List NEUTRAL_CLASS_FILES = List.of("LifecycleCapabilities", "LifecycleStage", + "LifecycleIdentity", "InvocationId", "AttemptId", "StepId", "ModelTurnId", "ToolCallId", "DelegationId", + "LifecycleEvent", "LifecycleExtension", "LifecycleObserver", "LifecycleTransformer", + "LifecycleMutationGuard", "AroundInterceptor", "AroundChain", "AdmissionGate", "GateDecision", + "ModelCallInvocation", "ModelCallOutcome", "ModelCallExecutor", "ToolCallInvocation", "ToolCallOutcome", + "ToolCallExecutor"); + + private static LifecycleIdentity identity() { + return LifecycleIdentity.builder(INVOCATION) + .attempt(AttemptId.of("attempt-1")) + .step(StepId.of("step-1")) + .modelTurn(MODEL_TURN) + .toolCall(TOOL_CALL) + .build(); + } + + private static ToolCallInvocation toolCall(String toolName, String arguments) { + return new ToolCallInvocation(identity(), TOOL_CALL, toolName, arguments); + } + + private static ModelCallInvocation modelCall() { + ModelRequest request = ModelRequest.builder().messages(List.of(ChatMessage.user("hello"))).build(); + return new ModelCallInvocation(identity(), MODEL_TURN, request); + } + + @Test + void observerSeesImmutableTypedEvents() { + List seen = new ArrayList<>(); + LifecycleObserver observer = new LifecycleObserver() { + @Override + public String id() { + return "audit"; + } + + @Override + public Set stages() { + return Set.of(LifecycleStage.TOOL_CALL); + } + + @Override + public void onEvent(LifecycleEvent event) { + seen.add(event); + } + }; + + StageRunner runner = new StageRunner(); + runner.observers.add(observer); + + LifecycleEvent event = new LifecycleEvent.ToolCallRequested(toolCall("search", "{}")); + LifecycleEvent result = runner.run(event, e -> new LifecycleEvent.ToolCallCompleted( + ((LifecycleEvent.ToolCallRequested) e).invocation(), ToolCallOutcome.success(TOOL_CALL, "ok"))); + + assertEquals(1, seen.size()); + assertEquals(LifecycleStage.TOOL_CALL, seen.get(0).stage()); + assertEquals(identity(), seen.get(0).identity()); + assertTrue(result instanceof LifecycleEvent.ToolCallCompleted); + + assertTrue(LifecycleEvent.ToolCallRequested.class.isRecord()); + for (Field field : LifecycleEvent.ToolCallRequested.class.getDeclaredFields()) { + if (field.isSynthetic()) { + continue; + } + assertTrue(Modifier.isFinal(field.getModifiers()), "event fields must be final: " + field.getName()); + } + } + + @Test + void restrictedTransformerRewritesOnlyAllowedFields() { + LifecycleTransformer argumentTransformer = transformer("redact", "{\"secret\":true}", "{\"secret\":false}"); + LifecycleEvent original = new LifecycleEvent.ToolCallRequested(toolCall("search", "{\"secret\":true}")); + + Optional rewritten = argumentTransformer.transform(original); + assertTrue(rewritten.isPresent()); + assertTrue(LifecycleMutationGuard.check(original, rewritten.get()).isEmpty()); + + StageRunner runner = new StageRunner(); + runner.transformers.add(argumentTransformer); + LifecycleEvent result = runner.run(original, e -> e); + + LifecycleEvent.ToolCallRequested applied = (LifecycleEvent.ToolCallRequested) result; + assertEquals("{\"secret\":false}", applied.invocation().arguments()); + assertEquals("search", applied.invocation().toolName()); + assertTrue(runner.failures.isEmpty()); + } + + @Test + void restrictedTransformerRejectsOutOfScopeRewrite() { + LifecycleTransformer renaming = new LifecycleTransformer() { + @Override + public String id() { + return "rename"; + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public Optional transform(LifecycleEvent event) { + if (!(event instanceof LifecycleEvent.ToolCallRequested requested)) { + return Optional.empty(); + } + return Optional.of(new LifecycleEvent.ToolCallRequested(new ToolCallInvocation( + requested.invocation().identity(), requested.invocation().toolCall(), "delete", + requested.invocation().arguments()))); + } + }; + LifecycleEvent original = new LifecycleEvent.ToolCallRequested(toolCall("search", "{\"secret\":true}")); + + LifecycleEvent rewritten = new LifecycleEvent.ToolCallRequested(toolCall("delete", "{\"secret\":true}")); + assertEquals("tool name must not change", + LifecycleMutationGuard.check(original, rewritten).orElseThrow()); + + StageRunner runner = new StageRunner(); + runner.transformers.add(renaming); + LifecycleEvent result = runner.run(original, e -> e); + + assertEquals("search", ((LifecycleEvent.ToolCallRequested) result).invocation().toolName()); + assertEquals("{\"secret\":true}", + ((LifecycleEvent.ToolCallRequested) result).invocation().arguments()); + assertEquals(1, runner.failures.size()); + assertEquals("rename", extensionId(runner.failures.get(0))); + } + + @Test + void admissionGateShortCircuitsDeniedStage() { + AtomicInteger chainCalls = new AtomicInteger(); + AdmissionGate gate = new AdmissionGate() { + @Override + public String id() { + return "policy"; + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public GateDecision admit(LifecycleEvent event) { + return GateDecision.deny("tool is not allowed for this tenant"); + } + }; + + StageRunner runner = new StageRunner(); + runner.gates.add(gate); + LifecycleEvent result = runner.run(new LifecycleEvent.ToolCallRequested(toolCall("search", "{}")), + e -> { + chainCalls.incrementAndGet(); + return e; + }); + + assertEquals(0, chainCalls.get()); + LifecycleEvent.StageExited exited = (LifecycleEvent.StageExited) result; + assertFalse(exited.successful()); + assertEquals("tool is not allowed for this tenant", exited.failureReason()); + } + + @Test + void approvalRequestDegradesToDenialWithoutApprovalChannel() { + AtomicInteger chainCalls = new AtomicInteger(); + AdmissionGate gate = new AdmissionGate() { + @Override + public String id() { + return "approval"; + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public GateDecision admit(LifecycleEvent event) { + return GateDecision.requireApproval("needs a human decision"); + } + }; + + StageRunner runner = new StageRunner(); + runner.gates.add(gate); + runner.approvalChannelAvailable = false; + LifecycleEvent result = runner.run(new LifecycleEvent.ToolCallRequested(toolCall("search", "{}")), + e -> { + chainCalls.incrementAndGet(); + return e; + }); + + assertEquals(0, chainCalls.get()); + LifecycleEvent.StageExited exited = (LifecycleEvent.StageExited) result; + assertFalse(exited.successful()); + assertEquals("needs a human decision", exited.failureReason()); + } + + @Test + void aroundInterceptorCanRewriteShortCircuitAndRetry() { + AtomicInteger chainCalls = new AtomicInteger(); + AroundInterceptor retrying = new AroundInterceptor() { + @Override + public String id() { + return "retry"; + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public LifecycleEvent intercept(LifecycleEvent event, AroundChain chain) { + chain.proceed(event); + return chain.proceed(event); + } + }; + + StageRunner runner = new StageRunner(); + runner.interceptors.add(retrying); + LifecycleEvent event = new LifecycleEvent.ToolCallRequested(toolCall("search", "{}")); + LifecycleEvent result = runner.run(event, e -> { + chainCalls.incrementAndGet(); + return new LifecycleEvent.ToolCallCompleted(((LifecycleEvent.ToolCallRequested) e).invocation(), + ToolCallOutcome.success(TOOL_CALL, "ok")); + }); + + assertEquals(2, chainCalls.get()); + assertTrue(result instanceof LifecycleEvent.ToolCallCompleted); + + AroundInterceptor shortCircuit = new AroundInterceptor() { + @Override + public String id() { + return "cache"; + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public LifecycleEvent intercept(LifecycleEvent event, AroundChain chain) { + return new LifecycleEvent.ToolCallCompleted(((LifecycleEvent.ToolCallRequested) event).invocation(), + ToolCallOutcome.success(TOOL_CALL, "cached")); + } + }; + + AtomicInteger shortCircuitCalls = new AtomicInteger(); + StageRunner shortCircuitRunner = new StageRunner(); + shortCircuitRunner.interceptors.add(shortCircuit); + LifecycleEvent cached = shortCircuitRunner.run(event, e -> { + shortCircuitCalls.incrementAndGet(); + return e; + }); + + assertEquals(0, shortCircuitCalls.get()); + assertEquals("cached", + ((LifecycleEvent.ToolCallCompleted) cached).outcome().result()); + } + + @Test + void failingObserverDoesNotBreakTheStage() { + AtomicInteger chainCalls = new AtomicInteger(); + LifecycleObserver broken = new LifecycleObserver() { + @Override + public String id() { + return "broken-observer"; + } + + @Override + public Set stages() { + return Set.of(LifecycleStage.TOOL_CALL); + } + + @Override + public void onEvent(LifecycleEvent event) { + throw new IllegalStateException("observer exploded"); + } + }; + + StageRunner runner = new StageRunner(); + runner.observers.add(broken); + LifecycleEvent result = runner.run(new LifecycleEvent.ToolCallRequested(toolCall("search", "{}")), e -> { + chainCalls.incrementAndGet(); + return e; + }); + + assertEquals(1, chainCalls.get()); + assertEquals(1, runner.failures.size()); + assertEquals("broken-observer", extensionId(runner.failures.get(0))); + assertEquals(LifecycleStage.TOOL_CALL, runner.failures.get(0).stage()); + } + + @Test + void failingAroundInterceptorAbortsTheStage() { + AtomicInteger chainCalls = new AtomicInteger(); + AroundInterceptor broken = new AroundInterceptor() { + @Override + public String id() { + return "broken-interceptor"; + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public LifecycleEvent intercept(LifecycleEvent event, AroundChain chain) { + throw new IllegalStateException("interceptor exploded"); + } + }; + + StageRunner runner = new StageRunner(); + runner.interceptors.add(broken); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> runner.run(new LifecycleEvent.ToolCallRequested(toolCall("search", "{}")), e -> { + chainCalls.incrementAndGet(); + return e; + })); + + assertEquals("interceptor exploded", failure.getMessage()); + assertEquals(0, chainCalls.get()); + assertEquals(1, runner.failures.size()); + assertEquals("broken-interceptor", extensionId(runner.failures.get(0))); + } + + @Test + void modelCallContractCarriesTheTrustedContextUnchanged() { + ModelCallInvocation invocation = modelCall(); + assertEquals(MODEL_TURN, invocation.modelTurn()); + assertEquals(identity(), invocation.identity()); + + ModelCallOutcome outcome = ModelCallOutcome.success(MODEL_TURN, ModelResponse.of(ChatMessage.assistant("hi"))); + assertTrue(outcome.isSuccess()); + + ModelCallExecutor executor = call -> ModelCallOutcome.failure(call.modelTurn(), "model unavailable"); + ModelCallOutcome failed = executor.execute(invocation); + assertFalse(failed.isSuccess()); + assertEquals("model unavailable", failed.error()); + } + + @Test + void neutralContractDoesNotDependOnGraphOrLoop() { + assertThrows(ClassNotFoundException.class, () -> Class.forName("io.github.agentic.ai.workflow.graph.KeyStrategy")); + assertThrows(ClassNotFoundException.class, + () -> Class.forName("io.github.agentic.ai.workflow.graph.CompiledGraph")); + assertThrows(ClassNotFoundException.class, () -> Class.forName("io.github.agentic.ai.workflow.graph.StateGraph")); + + for (String className : NEUTRAL_CLASS_FILES) { + String resource = "io/github/agentic/ai/agent/lifecycle/" + className + ".class"; + assertNotNull(getClass().getClassLoader().getResource(resource), resource + " must exist"); + String bytecode = readResource(resource); + assertFalse(bytecode.contains("workflow/graph"), className + " must not reference the graph module"); + assertFalse(bytecode.contains("workflow/loop"), className + " must not reference the loop module"); + } + } + + private static String extensionId(LifecycleEvent event) { + return ((LifecycleEvent.ExtensionFailed) event).extensionId(); + } + + private String readResource(String resource) { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resource)) { + assertNotNull(input, resource + " must be readable"); + return new String(input.readAllBytes(), StandardCharsets.ISO_8859_1); + } + catch (IOException e) { + throw new IllegalStateException("cannot read " + resource, e); + } + } + + private static LifecycleTransformer transformer(String id, String from, String to) { + return new LifecycleTransformer() { + @Override + public String id() { + return id; + } + + @Override + public LifecycleStage stage() { + return LifecycleStage.TOOL_CALL; + } + + @Override + public Optional transform(LifecycleEvent event) { + if (!(event instanceof LifecycleEvent.ToolCallRequested requested)) { + return Optional.empty(); + } + if (!requested.invocation().arguments().equals(from)) { + return Optional.empty(); + } + return Optional.of(new LifecycleEvent.ToolCallRequested(new ToolCallInvocation( + requested.invocation().identity(), requested.invocation().toolCall(), requested.invocation().toolName(), + to))); + } + }; + } + + /** + * A reference harness that applies the four extension kinds to one stage. + * + *

+ * The harness is deliberately part of the test: the chain executor and the engine + * wiring are separate issues, and this test pins down the contract semantics only. + */ + private static final class StageRunner { + + private final List observers = new ArrayList<>(); + + private final List transformers = new ArrayList<>(); + + private final List gates = new ArrayList<>(); + + private final List interceptors = new ArrayList<>(); + + private final List failures = new ArrayList<>(); + + private boolean approvalChannelAvailable = false; + + LifecycleEvent run(LifecycleEvent event, AroundChain chain) { + for (LifecycleObserver observer : observers) { + try { + observer.onEvent(event); + } + catch (RuntimeException e) { + failures.add(new LifecycleEvent.ExtensionFailed(event.stage(), event.identity(), observer.id(), + String.valueOf(e.getMessage()))); + } + } + + for (AdmissionGate gate : gates) { + if (!gate.stages().contains(event.stage())) { + continue; + } + GateDecision decision = gate.admit(event); + if (decision.isAllowed()) { + continue; + } + if (decision.requiresApproval() && approvalChannelAvailable) { + continue; + } + return new LifecycleEvent.StageExited(event.stage(), event.identity(), false, decision.reason()); + } + + LifecycleEvent current = event; + for (LifecycleTransformer transformer : transformers) { + if (!transformer.stages().contains(current.stage())) { + continue; + } + Optional rewritten = transformer.transform(current); + if (rewritten.isEmpty()) { + continue; + } + Optional violation = LifecycleMutationGuard.check(current, rewritten.get()); + if (violation.isPresent()) { + failures.add(new LifecycleEvent.ExtensionFailed(current.stage(), current.identity(), + transformer.id(), violation.get())); + continue; + } + current = rewritten.get(); + } + + final LifecycleEvent stageEvent = current; + for (AroundInterceptor interceptor : interceptors) { + if (!interceptor.stages().contains(current.stage())) { + continue; + } + AroundChain next = chain; + chain = e -> { + try { + return interceptor.intercept(e, next); + } + catch (RuntimeException ex) { + failures.add(new LifecycleEvent.ExtensionFailed(e.stage(), e.identity(), interceptor.id(), + String.valueOf(ex.getMessage()))); + throw ex; + } + }; + } + return chain.proceed(stageEvent); + } + + } + +} diff --git a/docs/architecture/lifecycle-extension-points.md b/docs/architecture/lifecycle-extension-points.md new file mode 100644 index 000000000..e36559ed9 --- /dev/null +++ b/docs/architecture/lifecycle-extension-points.md @@ -0,0 +1,198 @@ +# Agent Lifecycle Extension Points and Typed Plugin Contracts + +Status: proposed. Contract for +[#298](https://github.com/agentic-ai-java/argi/issues/298). + +This document is the contract decision for the agent lifecycle extension points. It +defines the catalog of stages, the plugin kinds that may attach to them, the identity +vocabulary the plugins observe, and the migration decision for the existing hooks and +interceptors. The executable part of the contract lives in the new +`io.github.agentic.ai.agent.lifecycle` package of `argi-agent-api`. + +## Scope and boundaries + +This issue owns the contract and the migration decision. It does not own the chain +executor, the declarative registration model or the engine wiring: + +| Concern | Owner | +| --- | --- | +| Chain executor that invokes extensions per stage | #299 | +| Declarative registration and scoping of extensions | #300 | +| Engine wiring that binds extensions to a stage | #301 | +| General run control, budget and cancellation | #200 | +| State boundary and state ownership | #203 | +| Registration, scope and resource lifecycle | #202 | + +The contract is engine-neutral by construction. `argi-agent-api` depends on `argi-api` +and `argi-runtime-api` only, so the types added here cannot depend on +`argi-workflow-graph-core`, on the loop runtime or on any provider SDK. A public +signature that reaches into the graph module is a defect, not a shortcut: the existing +`Hook` interface is exactly that case and is recorded in the migration table. + +Two boundaries are deliberate and are not negotiable per engine: + +- A workflow step is not a model turn. A workflow engine must not fabricate model + turns to satisfy the catalog, because the model turn identity is what plugins use to + correlate retries, budgets and per-turn accounting. +- A remote agent is not an in-process tool caller. The contract only exposes the + public run-level stages of a remote agent. It never claims that a local plugin can + intercept a tool call that executes inside the remote agent. + +## Extension point catalog + +The catalog is closed on purpose. Adding a stage means adding it here with its scope +and its capabilities, not adding an ad-hoc hook to one engine. + +| Stage | Scope | Neutral carrier | Sync | Async | Cancellable | Streaming | Resumable | Note | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| Definition validation and creation | common runtime | catalog only | yes | no | no | no | no | Validation failures are reported to the caller, not intercepted. | +| Run admission | common runtime | catalog only | yes | no | no | no | no | The gate kind applies here: admission is a refusal decision. | +| Run start | common runtime | catalog only | yes | no | no | no | no | Observers may be attached to audit the start of a run. | +| Context build | common runtime | catalog only | yes | no | no | no | no | The context is rebuilt per attempt; the identity is stable. | +| Model call | common runtime | `ModelCallInvocation`, `ModelCallOutcome`, `ModelCallExecutor` | yes | yes | yes | no | no | The typed carrier exists; the executor is the chain continuation. | +| Model stream | common runtime | catalog only | no | yes | yes | yes | no | The streamed carrier is intentionally not added yet. | +| Action decision | common runtime | catalog only | yes | no | no | no | no | The decision itself stays engine-owned; only the observation is neutral. | +| Tool call | common runtime | `ToolCallInvocation`, `ToolCallOutcome`, `ToolCallExecutor` | yes | yes | yes | no | no | The typed carrier exists; the executor is the chain continuation. | +| Delegation, handover and join | common runtime | catalog only | yes | yes | no | no | no | The delegated agent runs its own invocation with its own identity. | +| Workflow step | engine specific | catalog only | yes | yes | yes | no | yes | Only a workflow engine owns this stage; it does not create model turns. | +| Suspend and resume | engine specific | catalog only | no | yes | no | no | yes | The owning engine defines the resume protocol. | +| Output delivery | common runtime | catalog only | yes | yes | no | no | no | Delivery to the caller, not the production of the answer. | +| Termination | common runtime | catalog only | yes | no | no | no | no | Completion, failure, cancellation and release share one stage. | + +"catalog only" means the stage is part of the contract and may be observed, but the +neutral layer does not yet carry a typed request or response for it. An engine that +implements such a stage must state the mapping; it must not invent a second, engine +specific contract with the same name. + +## Plugin kinds + +Four kinds exist, and the catalog does not add a fifth. The kind of a plugin decides +what it is allowed to do, and the contract enforces the difference. + +| Kind | Interface | Input | Return | May modify | Rejection and failure semantics | +| --- | --- | --- | --- | --- | --- | +| Observer | `LifecycleObserver` | `LifecycleEvent` | nothing | nothing | Cannot reject. A failing observer is reported as `LifecycleEvent.ExtensionFailed` and the run continues. | +| Restricted transformer | `LifecycleTransformer` | `LifecycleEvent` | `Optional` | Only the fields the event declares as mutable | Cannot reject and cannot short-circuit. A rewrite outside the mutable boundary is rejected by `LifecycleMutationGuard`, reported as an extension failure and the original event is kept. | +| Around interceptor | `AroundInterceptor` | `LifecycleEvent`, `AroundChain` | `LifecycleEvent` | The event of its bound stage | May short-circuit by not calling the chain and may call the chain more than once. A failure aborts the stage and is reported as an extension failure. | +| Admission gate | `AdmissionGate` | `LifecycleEvent` | `GateDecision` | nothing | The only kind that may refuse work. `DENY` short-circuits the stage with a reason; `REQUIRE_APPROVAL` must degrade to a denial on an engine without an approval channel, never to an allow. | + +The four kinds compose instead of overlapping: + +- A transformer that wants to refuse work needs a gate. +- A gate that wants to change a request needs a transformer. +- An interceptor that wants to refuse work needs a gate; the interceptor itself only + short-circuits by producing a result instead of calling the chain. + +The transformation boundary is deliberately narrow: + +- a model call may replace its request, but not its model turn identity; +- a tool call may replace its arguments, but not its tool name and not its tool call + identity, because renaming a tool inside a transformation would silently redirect + the call to a different binding, which is an admission decision; +- every other event type is immutable. + +## Identity vocabulary + +The plugins need to correlate their own work with the work of the runtime. A single +opaque identifier cannot express that, so the contract names six identities instead. + +| Identity | Type | Generated by | Lifetime | Nullable | +| --- | --- | --- | --- | --- | +| Invocation | `InvocationId` | The engine, once per call into an agent | The whole run | no | +| Attempt | `AttemptId` | The engine, once per retry of a unit of work | One attempt | yes | +| Step | `StepId` | The engine, once per unit of work | The unit of work, stable across retries | yes | +| Model turn | `ModelTurnId` | The engine, once per model interaction | One model interaction | yes | +| Tool call | `ToolCallId` | The engine, once per tool interaction | One tool interaction | yes | +| Delegation | `DelegationId` | The engine, once per hand-over to another agent | The hand-over and its join | yes | + +`LifecycleIdentity` aggregates them. The invocation identity is always present; the +others exist only when the bound stage produces them. + +The message identity of `AgentEvent.messageId` is deliberately not part of this +vocabulary. A message identity names a message; a model turn identity names the model +interaction that produced it. Mixing them would make per-turn accounting impossible. + +The delegated agent is not represented by the delegation identity either. A delegated +agent runs its own invocation with its own invocation identity; the delegation +identity names the hand-over between them. + +## Read-only runtime control + +A plugin may read the trusted execution context of the run, and it may read nothing +else that could widen its own authority: + +| Readable | Not writable by a plugin | +| --- | --- | +| Tenant, principal, run and correlation identifiers of the trusted context | The tenant or the principal of the run | +| The deadline of the run | The deadline, to extend the run | +| The delegation scope of the run | The delegation scope, to widen the scope | +| The current budget consumption | The budget, to raise the limit | + +The trusted execution context is a read-only projection in the contract. There is no +mutator, no builder and no setter for it on any of the types added here, so a plugin +cannot rewrite a trusted identity even by accident. A plugin that needs a stronger +identity has to ask the runtime for a new one. + +## Existing hooks and interceptors: keep, adapt, migrate + +The existing extension points are not replaced. The decision per type is recorded +here so that the follow-up issues can be executed without re-opening the question. +"Keep" means the type stays exactly as it is, "adapt" means the type stays and gains +an adapter into the neutral contract, and "migrate" means the type will move into the +neutral layer in a later change with a deprecation period. + +| Existing type | Module | Decision | Reason | Path | +| --- | --- | --- | --- | --- | +| `ModelInterceptor` | `argi-agent-api` | adapt | Already a neutral around interceptor for the model call stage; it only lacks the typed invocation and outcome of the new contract | An adapter maps `ModelRequest`/`ModelResponse` onto `ModelCallInvocation`/`ModelCallOutcome`; the signature stays | +| `Interceptor` | `argi-agent-api` | keep | The name contract is still the smallest common denominator of an extension | The neutral kinds carry their own identifier, the base interface stays as it is | +| `ModelCallHandler` | `argi-agent-api` | adapt | The chain continuation of the model call stage | Mapped onto `ModelCallExecutor` by an adapter, no signature change | +| `ToolAuthorizationPolicy` | `argi-agent-api` | adapt | It is an admission decision, which is exactly what a gate is | The policy becomes a decision source of an `AdmissionGate`; its signature stays | +| `ToolInterceptor` | `argi-agent-framework` | migrate | The neutral layer has no tool call interception point without it, but the type lives in the module that depends on the graph runtime | A neutral `ToolCallInterceptor` will be added to `argi-agent-api` and the framework type will delegate to it; the framework signature stays in place | +| `ToolCallRequest` | `argi-agent-framework` | migrate | The neutral `ToolCallInvocation` carries the same facts without the untyped context map | The framework type will be built from the neutral invocation | +| `ToolCallHandler` | `argi-agent-framework` | migrate | The neutral `ToolCallExecutor` is the same continuation | The framework type will delegate to the neutral executor | +| `ToolCallResponse` | `argi-agent-framework` | migrate | The neutral `ToolCallOutcome` separates a result from an error instead of overloading one field | The framework type will be built from the neutral outcome | +| `StreamingModelInterceptor` | `argi-agent-framework` | migrate | Streaming is a stage of the catalog, but the neutral streamed carrier is not added yet | The stream carrier is added together with the engine wiring, not in the contract issue | +| `Hook` | `argi-agent-framework` | migrate | It imports `io.github.agentic.ai.workflow.graph.KeyStrategy` and holds the agent and the state, so it cannot stay in a public neutral signature | The position concept becomes `LifecycleStage`; the graph dependency is dropped in the neutral type | +| `AgentHook` | `argi-agent-framework` | migrate | It holds `ReactAgent` and the overall state, which are engine objects | The neutral hook observes a stage instead of holding the agent | +| `HookPosition` | `argi-agent-framework` | migrate | `BEFORE_AGENT`, `AFTER_AGENT`, `BEFORE_MODEL` and `AFTER_MODEL` are a subset of the catalog | Mapped onto `LifecycleStage` values | +| `ModelHook` | `argi-agent-framework` | migrate | Same reason as `AgentHook` | Mapped onto an observer or an around interceptor of the model call stage | + +Two rules apply to every row of this table: + +- A public signature must not depend on the graph module or on the loop runtime. The + neutral types depend on `argi-api` and `argi-runtime-api` only. +- A type is not renamed or rewritten without a real benefit. The existing + interceptors keep working; the neutral contract is added next to them and the + migration happens in the follow-up issues. + +## Contract example and verification + +The contract is verified without a model, a tool binding and an engine. The test +`LifecycleContractTest` in `argi-agent-api` drives the four kinds directly and pins +down five paths: + +1. observation of an immutable, typed event; +2. a transformation inside the mutable boundary; +3. a rejection that short-circuits the stage; +4. a wrapping that rewrites, short-circuits and repeats the chain; +5. the failure semantics of a failing observer and of a failing interceptor. + +The test also asserts the dependency boundary: the classpath of `argi-agent-api` +contains no type of the graph module, and no class of the neutral package references +the graph or the loop module. + +## Breaking change assessment + +| Question | Answer | +| --- | --- | +| Smaller option | A documentation-only change without the neutral package | +| Why not smaller | The catalog would stay unenforceable: the tool call stage would still have no neutral interception point, and the identity vocabulary would remain implicit. The follow-up issues could not be implemented against a document alone | +| Benefit | One contract for every engine, an executable transformation boundary, and a dependency direction that keeps the graph runtime out of public signatures | +| Impact | Additive only. No existing signature changes, no module dependency changes, no configuration or persistence format changes | +| Target version | The current development line, next minor release | +| Migration | None required. Existing hooks and interceptors keep working; the adapters and the framework side migration are follow-up work in #299, #300 and #301 | + +The breaking part of this work is the *future* migration of the framework types. +When `ToolInterceptor` and `Hook` move, the smaller option is a neutral type plus an +adapter in the framework module, so that no consumer has to change its imports in one +step. That migration is explicitly out of scope here. From 3956636b1f031133115e6b480a257c42910d4576 Mon Sep 17 00:00:00 2001 From: konlue <2362586639@qq.com> Date: Tue, 3 Nov 2026 00:29:16 +0800 Subject: [PATCH 2/5] fix(agent): isolate observed model requests and preserve the trusted execution context (issue #298 review) --- .../lifecycle/LifecycleMutationGuard.java | 7 ++ .../agent/lifecycle/ModelCallInvocation.java | 19 ++++ .../lifecycle/LifecycleContractTest.java | 88 +++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java index 4f9b866b5..ce162188e 100644 --- a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/LifecycleMutationGuard.java @@ -63,6 +63,13 @@ public static Optional check(LifecycleEvent original, LifecycleEvent rew if (!before.invocation().modelTurn().equals(after.invocation().modelTurn())) { return Optional.of("model turn identity must not change"); } + // The trusted execution context is run identity: a replacement that swaps + // the tenant, principal or budget (or nulls it out) must be rejected even + // when the lifecycle IDs are preserved. + if (!java.util.Objects.equals(before.invocation().request().getTrustedExecutionContext(), + after.invocation().request().getTrustedExecutionContext())) { + return Optional.of("trusted execution context must not change"); + } return Optional.empty(); } if (original instanceof LifecycleEvent.ToolCallRequested before) { diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java index 1e3a5fe7d..091084a84 100644 --- a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java @@ -17,6 +17,8 @@ import io.github.agentic.ai.agent.interceptor.ModelRequest; +import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -44,6 +46,23 @@ public record ModelCallInvocation(LifecycleIdentity identity, ModelTurnId modelT Objects.requireNonNull(identity, "identity must not be null"); Objects.requireNonNull(modelTurn, "modelTurn must not be null"); Objects.requireNonNull(request, "request must not be null"); + request = defensiveCopy(request); + } + + /** + * Returns a copy of the request whose collection-valued accessors are immutable: + * observers must not be able to rewrite the execution input (clearing the message + * list or mutating the context map) through the invocation reference. Transforming + * extensions observe this copy and rebuild a replacement from it; the executor + * keeps running on the original request it holds, so a mutated copy would have no + * effect on dispatch. + */ + private static ModelRequest defensiveCopy(ModelRequest request) { + return ModelRequest.builder(request) + .messages(List.copyOf(request.getMessages())) + .context(Map.copyOf(request.getContext())) + .attributes(Map.copyOf(request.getAttributes())) + .build(); } } diff --git a/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java index 96ac1d550..fb85bc2cf 100644 --- a/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java +++ b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java @@ -16,6 +16,11 @@ package io.github.agentic.ai.agent.lifecycle; import io.github.agentic.ai.agent.interceptor.ModelRequest; +import io.github.agentic.ai.runtime.execution.ExecutionBudget; +import io.github.agentic.ai.runtime.execution.DelegationScope; +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.agent.interceptor.ModelResponse; import io.github.agentic.ai.api.chat.ChatMessage; import java.io.IOException; @@ -24,7 +29,9 @@ import java.lang.reflect.Modifier; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -33,6 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -393,6 +401,86 @@ void modelCallContractCarriesTheTrustedContextUnchanged() { assertEquals("model unavailable", failed.error()); } + // issue #298 review: request isolation and trusted-context preservation + + @Test + void invocationRequestIsIsolatedFromTheCallerAndTheExecutor() { + List callerMessages = new ArrayList<>(); + callerMessages.add(ChatMessage.user("hello")); + Map callerContext = new HashMap<>(); + callerContext.put("k", "v"); + ModelRequest request = ModelRequest.builder().messages(callerMessages) + .context(callerContext).attributes(callerContext).build(); + ModelCallInvocation invocation = new ModelCallInvocation(identity(), MODEL_TURN, request); + + // The invocation holds its own defensive copy, not the request the executor + // runs on: mutating what the observer can reach must not touch the caller's + // collections or the original request the dispatch keeps using. + assertNotSame(request, invocation.request()); + invocation.request().getMessages().clear(); + invocation.request().getContext().put("k", "rewritten"); + // attributes are transmitted as an immutable map: writes are rejected outright. + assertThrows(UnsupportedOperationException.class, + () -> invocation.request().getAttributes().put("k", "rewritten")); + assertEquals("v", callerContext.get("k")); + assertEquals(1, callerMessages.size()); + // The executor's own request keeps its content. + assertEquals("hello", request.getMessages().get(0).text()); + assertEquals("v", request.getContext().get("k")); + } + + @Test + void mutationGuardRejectsTrustedExecutionContextSwap() { + TrustedExecutionContext tenantA = TrustedExecutionContext.builder() + .tenant(new TenantReference("run", "tenant-a")) + .principal(new PrincipalReference("run", "principal-a")) + .runId("run").correlationId("correlation").budget(ExecutionBudget.unlimited()) + .delegationScope(DelegationScope.none()).build(); + TrustedExecutionContext tenantB = TrustedExecutionContext.builder() + .tenant(new TenantReference("run", "tenant-b")) + .principal(new PrincipalReference("run", "principal-b")) + .runId("run").correlationId("correlation").budget(ExecutionBudget.unlimited()) + .delegationScope(DelegationScope.none()).build(); + ModelCallInvocation original = modelCallWithTrustedContext(tenantA); + // Swapping the tenant (or nulling the context out) must be rejected even when + // the lifecycle IDs are preserved. + ModelCallInvocation swappedTenant = new ModelCallInvocation(original.identity(), original.modelTurn(), + ModelRequest.builder(original.request()).trustedExecutionContext(tenantB).build()); + ModelCallInvocation nulledContext = new ModelCallInvocation(original.identity(), original.modelTurn(), + ModelRequest.builder(original.request()).trustedExecutionContext(null).build()); + LifecycleEvent originalEvent = new LifecycleEvent.ModelCallRequested(original); + assertEquals("trusted execution context must not change", + LifecycleMutationGuard.check(originalEvent, + new LifecycleEvent.ModelCallRequested(swappedTenant)).orElseThrow()); + assertEquals("trusted execution context must not change", + LifecycleMutationGuard.check(originalEvent, + new LifecycleEvent.ModelCallRequested(nulledContext)).orElseThrow()); + } + + @Test + void mutationGuardStillAllowsLegitimateRequestTransform() { + TrustedExecutionContext tenantA = TrustedExecutionContext.builder() + .tenant(new TenantReference("run", "tenant-a")) + .principal(new PrincipalReference("run", "principal-a")) + .runId("run").correlationId("correlation").budget(ExecutionBudget.unlimited()) + .delegationScope(DelegationScope.none()).build(); + ModelCallInvocation original = modelCallWithTrustedContext(tenantA); + // A legitimate rewrite keeps the trusted execution context and changes only + // message content (e.g. a compaction or redaction pass). + ModelCallInvocation rewrittenInvocation = new ModelCallInvocation(original.identity(), + original.modelTurn(), ModelRequest.builder(original.request()) + .messages(List.of(ChatMessage.user("redacted"))).build()); + LifecycleEvent originalEvent = new LifecycleEvent.ModelCallRequested(original); + LifecycleEvent rewrittenEvent = new LifecycleEvent.ModelCallRequested(rewrittenInvocation); + assertTrue(LifecycleMutationGuard.check(originalEvent, rewrittenEvent).isEmpty()); + } + + private static ModelCallInvocation modelCallWithTrustedContext(TrustedExecutionContext trusted) { + ModelRequest request = ModelRequest.builder().messages(List.of(ChatMessage.user("hello"))) + .trustedExecutionContext(trusted).build(); + return new ModelCallInvocation(identity(), MODEL_TURN, request); + } + @Test void neutralContractDoesNotDependOnGraphOrLoop() { assertThrows(ClassNotFoundException.class, () -> Class.forName("io.github.agentic.ai.workflow.graph.KeyStrategy")); From 8ef2a88410f12ad533454a5a65ceb560c48cb87f Mon Sep 17 00:00:00 2001 From: konlue Date: Wed, 4 Nov 2026 23:47:10 +0800 Subject: [PATCH 3/5] fix(agent): expose a genuinely read-only request snapshot on model call invocations (issue #298 review) --- .../agent/lifecycle/ModelCallInvocation.java | 49 +++++++++++++++---- .../lifecycle/LifecycleContractTest.java | 45 ++++++++++++++--- 2 files changed, 79 insertions(+), 15 deletions(-) diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java index 091084a84..42aa8294a 100644 --- a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java @@ -16,7 +16,9 @@ package io.github.agentic.ai.agent.lifecycle; import io.github.agentic.ai.agent.interceptor.ModelRequest; +import io.github.agentic.ai.api.chat.ChatMessage; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Objects; @@ -46,23 +48,52 @@ public record ModelCallInvocation(LifecycleIdentity identity, ModelTurnId modelT Objects.requireNonNull(identity, "identity must not be null"); Objects.requireNonNull(modelTurn, "modelTurn must not be null"); Objects.requireNonNull(request, "request must not be null"); - request = defensiveCopy(request); + request = readOnlySnapshot(request); } /** - * Returns a copy of the request whose collection-valued accessors are immutable: - * observers must not be able to rewrite the execution input (clearing the message - * list or mutating the context map) through the invocation reference. Transforming - * extensions observe this copy and rebuild a replacement from it; the executor - * keeps running on the original request it holds, so a mutated copy would have no - * effect on dispatch. + * Returns a genuinely read-only snapshot of the request. Two layers protect the + * event: the snapshot is taken from the caller's request, so later changes to the + * caller's collections are invisible; and the snapshot's collection accessors are + * immutable views, so an extension cannot rewrite what subsequent extensions observe + * (clearing the message list or mutating the context map fails). Transforming + * extensions rebuild a replacement through {@link ModelRequest#builder(ModelRequest)}; + * the executor keeps running on the request it holds, so the event never becomes a + * mutation channel into dispatch. */ - private static ModelRequest defensiveCopy(ModelRequest request) { - return ModelRequest.builder(request) + private static ModelRequest readOnlySnapshot(ModelRequest request) { + ModelRequest snapshot = ModelRequest.builder(request) .messages(List.copyOf(request.getMessages())) .context(Map.copyOf(request.getContext())) .attributes(Map.copyOf(request.getAttributes())) .build(); + return new ReadOnlyModelRequest(snapshot); + } + + /** + * A request view whose editable collections are exposed as immutable. The framework + * keeps per-subscription {@link ModelRequest} copies mutable by design (subscription + * isolation); the lifecycle event is the read-only surface on top of a snapshot. + */ + private static final class ReadOnlyModelRequest extends ModelRequest { + + private ReadOnlyModelRequest(ModelRequest source) { + super(source.getSystemMessage(), source.getMessages(), source.getOptions(), source.getChatClient(), + source.getToolRegistry(), source.getTools(), source.hasToolSelection(), + source.getDynamicToolRegistry(), source.getToolDescriptions(), source.getContext(), + source.getAttributes(), source.getTrustedExecutionContext().orElse(null)); + } + + @Override + public List getMessages() { + return Collections.unmodifiableList(super.getMessages()); + } + + @Override + public Map getContext() { + return Collections.unmodifiableMap(super.getContext()); + } + } } diff --git a/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java index fb85bc2cf..e3d1200fa 100644 --- a/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java +++ b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java @@ -413,13 +413,13 @@ void invocationRequestIsIsolatedFromTheCallerAndTheExecutor() { .context(callerContext).attributes(callerContext).build(); ModelCallInvocation invocation = new ModelCallInvocation(identity(), MODEL_TURN, request); - // The invocation holds its own defensive copy, not the request the executor - // runs on: mutating what the observer can reach must not touch the caller's - // collections or the original request the dispatch keeps using. + // The invocation holds its own read-only snapshot, not the request the executor + // runs on: every collection accessor of the observed request rejects writes, so + // an extension cannot rewrite what subsequent extensions observe. assertNotSame(request, invocation.request()); - invocation.request().getMessages().clear(); - invocation.request().getContext().put("k", "rewritten"); - // attributes are transmitted as an immutable map: writes are rejected outright. + assertThrows(UnsupportedOperationException.class, () -> invocation.request().getMessages().clear()); + assertThrows(UnsupportedOperationException.class, + () -> invocation.request().getContext().put("k", "rewritten")); assertThrows(UnsupportedOperationException.class, () -> invocation.request().getAttributes().put("k", "rewritten")); assertEquals("v", callerContext.get("k")); @@ -429,6 +429,39 @@ void invocationRequestIsIsolatedFromTheCallerAndTheExecutor() { assertEquals("v", request.getContext().get("k")); } + // The event is a snapshot: later changes to the caller's collections are invisible + // to extensions that observe the same invocation. + @Test + void invocationSnapshotIsUnaffectedByLaterCallerMutations() { + List callerMessages = new ArrayList<>(); + callerMessages.add(ChatMessage.user("hello")); + Map callerContext = new HashMap<>(); + callerContext.put("k", "v"); + ModelRequest request = ModelRequest.builder().messages(callerMessages).context(callerContext).build(); + ModelCallInvocation invocation = new ModelCallInvocation(identity(), MODEL_TURN, request); + + callerMessages.clear(); + callerMessages.add(ChatMessage.user("replaced")); + callerContext.put("k", "rewritten"); + + assertEquals("hello", invocation.request().getMessages().get(0).text()); + assertEquals(1, invocation.request().getMessages().size()); + assertEquals("v", invocation.request().getContext().get("k")); + } + + // A request rebuilt from the read-only view (the transformer path) still carries + // the full content, so the view does not break legitimate transforms. + @Test + void aTransformRebuiltFromTheReadOnlyViewKeepsTheContent() { + ModelCallInvocation invocation = modelCall(); + ModelRequest rebuilt = ModelRequest.builder(invocation.request()) + .systemMessage(ChatMessage.system("extra instruction")) + .build(); + + assertEquals(invocation.request().getMessages(), rebuilt.getMessages()); + assertEquals("extra instruction", rebuilt.getSystemMessage().text()); + } + @Test void mutationGuardRejectsTrustedExecutionContextSwap() { TrustedExecutionContext tenantA = TrustedExecutionContext.builder() From d6e6d16848200b23e85ac5488c042d4fdd7dbbf7 Mon Sep 17 00:00:00 2001 From: konlue Date: Thu, 5 Nov 2026 23:29:20 +0800 Subject: [PATCH 4/5] fix(agent): freeze collection values nested in invocation context and attributes (issue #298 review) --- .../agent/lifecycle/ModelCallInvocation.java | 36 ++++++++++++--- .../lifecycle/LifecycleContractTest.java | 44 +++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java index 42aa8294a..62fd54c95 100644 --- a/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java +++ b/argi-agent/argi-agent-api/src/main/java/io/github/agentic/ai/agent/lifecycle/ModelCallInvocation.java @@ -19,9 +19,11 @@ import io.github.agentic.ai.api.chat.ChatMessage; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** * A model call in the neutral lifecycle contract. @@ -56,20 +58,44 @@ public record ModelCallInvocation(LifecycleIdentity identity, ModelTurnId modelT * event: the snapshot is taken from the caller's request, so later changes to the * caller's collections are invisible; and the snapshot's collection accessors are * immutable views, so an extension cannot rewrite what subsequent extensions observe - * (clearing the message list or mutating the context map fails). Transforming - * extensions rebuild a replacement through {@link ModelRequest#builder(ModelRequest)}; - * the executor keeps running on the request it holds, so the event never becomes a + * (clearing the message list or mutating the context map fails). Collection-valued + * context and attribute entries are frozen as well, so a nested list or map shared + * with the caller cannot become the mutation channel either. Transforming extensions + * rebuild a replacement through {@link ModelRequest#builder(ModelRequest)}; the + * executor keeps running on the request it holds, so the event never becomes a * mutation channel into dispatch. */ private static ModelRequest readOnlySnapshot(ModelRequest request) { + Map context = new LinkedHashMap<>(); + request.getContext().forEach((key, value) -> context.put(key, frozen(value))); + Map attributes = new LinkedHashMap<>(); + request.getAttributes().forEach((key, value) -> attributes.put(key, frozen(value))); ModelRequest snapshot = ModelRequest.builder(request) .messages(List.copyOf(request.getMessages())) - .context(Map.copyOf(request.getContext())) - .attributes(Map.copyOf(request.getAttributes())) + .context(context) + .attributes(attributes) .build(); return new ReadOnlyModelRequest(snapshot); } + /** + * Freezes the supported mutable value types. Values of other types (the executor's + * own objects, provider clients, builders) are shared by reference: the contract + * freezes what an extension can reach through collection accessors. + */ + private static Object frozen(Object value) { + if (value instanceof List list) { + return List.copyOf(list); + } + if (value instanceof Map map) { + return Map.copyOf(map); + } + if (value instanceof Set set) { + return Set.copyOf(set); + } + return value; + } + /** * A request view whose editable collections are exposed as immutable. The framework * keeps per-subscription {@link ModelRequest} copies mutable by design (subscription diff --git a/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java index e3d1200fa..4d34db18d 100644 --- a/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java +++ b/argi-agent/argi-agent-api/src/test/java/io/github/agentic/ai/agent/lifecycle/LifecycleContractTest.java @@ -462,6 +462,50 @@ void aTransformRebuiltFromTheReadOnlyViewKeepsTheContent() { assertEquals("extra instruction", rebuilt.getSystemMessage().text()); } + // issue #298 review, second round: collection values nested inside the context are + // frozen too — clearing such a list through the event must fail and must not + // touch the caller's list. + @Test + void nestedContextValuesAreFrozenInTheSnapshot() { + List shared = new ArrayList<>(List.of("a", "b")); + Map callerContext = new HashMap<>(); + callerContext.put("history", shared); + callerContext.put("flags", new HashMap<>(Map.of("k", "v"))); + ModelRequest request = ModelRequest.builder().context(callerContext).build(); + ModelCallInvocation invocation = new ModelCallInvocation(identity(), MODEL_TURN, request); + + // The observed nested list is a frozen copy: writes are rejected... + @SuppressWarnings("unchecked") + List observed = (List) invocation.request().getContext().get("history"); + assertThrows(UnsupportedOperationException.class, observed::clear); + assertThrows(UnsupportedOperationException.class, () -> observed.add("c")); + // ...and the caller's list keeps its content and independence. + assertEquals(List.of("a", "b"), shared); + shared.clear(); + assertEquals(List.of("a", "b"), invocation.request().getContext().get("history")); + + // The same freeze applies to nested maps. + @SuppressWarnings("unchecked") + Map observedFlags = (Map) invocation.request().getContext().get("flags"); + assertThrows(UnsupportedOperationException.class, () -> observedFlags.put("k", "rewritten")); + } + + // A collection-valued attribute is frozen the same way as a context entry. + @Test + void nestedAttributeValuesAreFrozenInTheSnapshot() { + List shared = new ArrayList<>(List.of("x")); + Map callerAttributes = new HashMap<>(); + callerAttributes.put("trace", shared); + ModelRequest request = ModelRequest.builder().attributes(callerAttributes).build(); + ModelCallInvocation invocation = new ModelCallInvocation(identity(), MODEL_TURN, request); + + @SuppressWarnings("unchecked") + List observed = (List) invocation.request().getAttributes().get("trace"); + assertThrows(UnsupportedOperationException.class, observed::clear); + shared.add("y"); + assertEquals(List.of("x"), invocation.request().getAttributes().get("trace")); + } + @Test void mutationGuardRejectsTrustedExecutionContextSwap() { TrustedExecutionContext tenantA = TrustedExecutionContext.builder() From 72e0791f4838cade7f996e8d7d7a55e33b4f22a0 Mon Sep 17 00:00:00 2001 From: konlue Date: Sat, 7 Nov 2026 11:43:18 +0800 Subject: [PATCH 5/5] ci: retrigger the test job (unrelated scheduling flake in AgentToolNodeExecutionLeaseTest, see review comment)