diff --git a/.github/workflows/java-regression.yml b/.github/workflows/java-regression.yml index 79bff82f..23e08630 100644 --- a/.github/workflows/java-regression.yml +++ b/.github/workflows/java-regression.yml @@ -51,7 +51,7 @@ jobs: CHANGED_FILES="$(git diff --name-only "$BASE" "$HEAD")" printf '%s\n' "$CHANGED_FILES" - if printf '%s\n' "$CHANGED_FILES" | grep -Eq '^(pom\.xml|ai4j-extension-api/|ai4j-plugin-ask-user/|ai4j/|ai4j-agent/|ai4j-coding/|ai4j-cli/|ai4j-spring-boot-starter/|ai4j-flowgram-spring-boot-starter/|ai4j-flowgram-demo/|ai4j-bom/|\.github/workflows/java-regression\.yml$)'; then + if printf '%s\n' "$CHANGED_FILES" | grep -Eq '^(pom\.xml|ai4j-extension-api/|ai4j-plugin-ask-user/|ai4j/|ai4j-agent/|ai4j-harness/|ai4j-coding/|ai4j-cli/|ai4j-spring-boot-starter/|ai4j-flowgram-spring-boot-starter/|ai4j-flowgram-demo/|ai4j-bom/|\.github/workflows/java-regression\.yml$)'; then echo "java=true" >> "$GITHUB_OUTPUT" else echo "java=false" >> "$GITHUB_OUTPUT" @@ -125,6 +125,7 @@ jobs: - ai4j-plugin-ask-user - ai4j - ai4j-agent + - ai4j-harness - ai4j-coding - ai4j-cli - ai4j-spring-boot-starter diff --git a/.gitignore b/.gitignore index 67590c6d..3e351b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,10 @@ javadoc-ai4j.log !AGENTS.md .harness/ harness/ + +# The ai4j-harness module uses a package named "harness"; keep its source +# and tests trackable even though the private HA ledger is also named harness. +!/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ +!/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/** +!/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/ +!/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/** diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agent.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agent.java index 1b24e6e6..5c094fdb 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agent.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/Agent.java @@ -35,6 +35,16 @@ public AgentResult run(AgentRequest request) throws Exception { return runtime.run(baseContext, request); } + /** + * Returns the immutable-by-convention base context used by this Agent. + * Harness integrations use {@link AgentContext#toBuilder()} to create a + * per-execution overlay; callers should not mutate the returned context in + * place while a run is active. + */ + public AgentContext getContext() { + return baseContext; + } + public void runStream(AgentRequest request, AgentListener listener) throws Exception { runtime.runStream(baseContext, request, listener); } @@ -44,23 +54,58 @@ public AgentResult runStreamResult(AgentRequest request, AgentListener listener) } public AgentSession newSession() { - return newSession(AgentSessionMetadata.create(), null); + return newSession(baseContext, AgentSessionMetadata.create(), null); } - private AgentSession newSession(AgentSessionMetadata metadata, String runId) { - AgentMemory memory = memorySupplier == null ? baseContext.getMemory() : memorySupplier.get(); + /** + * Creates a session from a context overlay while retaining the Agent's + * configured memory supplier and runtime. This is intentionally additive: + * existing callers continue to use {@link #newSession()}. + */ + public AgentSession newSessionWithContext(AgentContext context) { + return newSession(context == null ? baseContext : context, AgentSessionMetadata.create(), null); + } + + /** + * Creates a fresh session with a host-selected stable identity. This is + * useful for business conversations whose id is owned by the application; + * it does not bind that identity to a Harness task. + */ + public AgentSession newSessionWithIdentity(String sessionId, + String runId, + AgentContext context) { + AgentSessionMetadata metadata = new AgentSessionMetadata(sessionId, 0L, 0L, null); + return newSession(context == null ? baseContext : context, metadata, runId); + } + + private AgentSession newSession(AgentContext context, AgentSessionMetadata metadata, String runId) { + AgentContext sourceContext = context == null ? baseContext : context; + if (sourceContext == null) { + throw new IllegalStateException("agent context is required"); + } + AgentMemory memory = memorySupplier == null ? sourceContext.getMemory() : memorySupplier.get(); AgentSessionMetadata sessionMetadata = metadata == null ? AgentSessionMetadata.create() : metadata.copy(); AgentSessionEventLog eventLog = new InMemoryAgentSessionEventLog(); - AgentContext sessionContext = baseContext.toBuilder() + AgentContext sessionContext = sourceContext.toBuilder() .memory(memory) .sessionId(sessionMetadata.getSessionId()) - .eventPublisher(sessionEventPublisher(eventLog)) + .eventPublisher(sessionEventPublisher(eventLog, sourceContext)) .build(); return new AgentSession(runtime, sessionContext, sessionMetadata, eventLog, sessionStore, runId); } public AgentSession newSession(AgentSessionSnapshot snapshot) { + return newSession(snapshot, baseContext); + } + + /** + * Restores a session using a per-execution context overlay. The snapshot + * remains the source of session state; the overlay only supplies runtime + * facilities such as Harness tools, listeners, and bounded options. + */ + public AgentSession newSession(AgentSessionSnapshot snapshot, AgentContext context) { AgentSession session = newSession( + context == null ? baseContext : context, snapshot == null ? null : snapshot.getMetadata(), snapshot == null ? null : snapshot.getRunId() ); @@ -83,8 +128,9 @@ public AgentSessionStore getSessionStore() { return sessionStore; } - private AgentEventPublisher sessionEventPublisher(final AgentSessionEventLog eventLog) { - AgentEventPublisher basePublisher = baseContext == null ? null : baseContext.getEventPublisher(); + private AgentEventPublisher sessionEventPublisher(final AgentSessionEventLog eventLog, + AgentContext sourceContext) { + AgentEventPublisher basePublisher = sourceContext == null ? null : sourceContext.getEventPublisher(); List baseListeners = basePublisher == null ? null : basePublisher.getListeners(); AgentEventPublisher publisher = new AgentEventPublisher(baseListeners); publisher.addListener(new AgentListener() { diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentExecutionStatus.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentExecutionStatus.java new file mode 100644 index 00000000..0a690fd0 --- /dev/null +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentExecutionStatus.java @@ -0,0 +1,9 @@ +package io.github.lnyocly.ai4j.agent; + +/** Structured status for a bounded Agent invocation. */ +public enum AgentExecutionStatus { + COMPLETED, + WAITING, + CONTINUATION_REQUIRED, + FAILED +} diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRequest.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRequest.java index 18b9aa6e..7ff60f33 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRequest.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentRequest.java @@ -18,6 +18,12 @@ public class AgentRequest { public static final String METADATA_KEY_TURN_ID = "turnId"; public static final String METADATA_KEY_SESSION_ID = "sessionId"; public static final String METADATA_KEY_EVENT_ID = "eventId"; + /** Stable Harness scope selected by the host for one execution. */ + public static final String METADATA_KEY_HARNESS_SCOPE = "harnessScope"; + /** Durable Harness execution identity injected into an Agent request. */ + public static final String METADATA_KEY_HARNESS_EXECUTION_ID = "harnessExecutionId"; + /** Durable Harness Task identity injected when the execution is Task-bound. */ + public static final String METADATA_KEY_HARNESS_TASK_ID = "harnessTaskId"; private Object input; diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentResult.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentResult.java index 9b5e4cc1..76763449 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentResult.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentResult.java @@ -59,6 +59,15 @@ public class AgentResult { private String currency; + /** Optional structured status for bounded or asynchronous runs. */ + private AgentExecutionStatus executionStatus; + + /** Stable operation identity when the run is waiting on asynchronous work. */ + private String operationId; + + /** Stable wait identity when the host must resume this run later. */ + private String waitId; + /** Compatibility constructor retained for the pre-cache-accounting result shape. */ public AgentResult(String runId, String sessionId, diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentSession.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentSession.java index 45a1ff1e..4c034820 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentSession.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/AgentSession.java @@ -114,6 +114,12 @@ public AgentSession putMetadata(String key, Object value) { return this; } + /** Replaces an asynchronous tool's pending result in the session memory. */ + public boolean replaceToolOutput(String callId, String output) { + return context != null && context.getMemory() != null + && context.getMemory().replaceToolOutput(callId, output); + } + public Object getMetadata(String key) { return metadata.getAttribute(key); } @@ -264,7 +270,7 @@ private AgentRequest enrichRequest(AgentRequest request, String turnId) { if (metadata != null && metadata.getSessionId() != null) { metadataMap.put(AgentRequest.METADATA_KEY_SESSION_ID, metadata.getSessionId()); } - metadataMap.put(AgentRequest.METADATA_KEY_RUN_ID, runId); + metadataMap.put(AgentRequest.METADATA_KEY_RUN_ID, this.runId); if (turnId != null) { metadataMap.put(AgentRequest.METADATA_KEY_TURN_ID, turnId); } diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeActPendingToolException.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeActPendingToolException.java new file mode 100644 index 00000000..f5df9c30 --- /dev/null +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeActPendingToolException.java @@ -0,0 +1,13 @@ +package io.github.lnyocly.ai4j.agent.codeact; + +/** + * Internal control-flow signal used to stop a CodeAct program at a pending + * asynchronous tool call. The owning CodeExecutor converts it to a + * {@link CodeExecutionResult}; it must never escape to an application caller. + */ +final class CodeActPendingToolException extends RuntimeException { + + CodeActPendingToolException(String callId) { + super("CodeAct tool execution is waiting: " + callId); + } +} diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionRequest.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionRequest.java index 4eb07aa9..eaa66e9d 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionRequest.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionRequest.java @@ -21,4 +21,7 @@ public class CodeExecutionRequest { private String user; private Long timeoutMs; + + /** Runtime call id that owns tool calls made from this CodeAct program. */ + private String parentCallId; } diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionResult.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionResult.java index e4bbc234..b405829d 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionResult.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/CodeExecutionResult.java @@ -1,5 +1,6 @@ package io.github.lnyocly.ai4j.agent.codeact; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; import lombok.Builder; import lombok.Data; @@ -13,7 +14,29 @@ public class CodeExecutionResult { private String error; + /** Structured lifecycle state for a bounded or asynchronous execution. */ + private AgentToolExecutionStatus status; + + /** Durable identity of the external operation, when execution is waiting. */ + private String operationId; + + /** Durable host wait identity, when execution is waiting. */ + private String waitId; + + /** Inner tool call that caused the CodeAct program to wait. */ + private String pendingToolCallId; + + /** Outer CodeAct call that owns {@link #pendingToolCallId}. */ + private String parentCallId; + public boolean isSuccess() { - return error == null || error.isEmpty(); + return !isWaiting() + && !AgentToolExecutionStatus.FAILED.equals(status) + && !AgentToolExecutionStatus.UNKNOWN.equals(status) + && (error == null || error.isEmpty()); + } + + public boolean isWaiting() { + return AgentToolExecutionStatus.WAITING.equals(status); } } diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/GraalVmCodeExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/GraalVmCodeExecutor.java index ed9a1e8c..e4f34a4f 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/GraalVmCodeExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/GraalVmCodeExecutor.java @@ -2,6 +2,10 @@ import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.graalvm.polyglot.Context; import org.graalvm.polyglot.HostAccess; @@ -15,6 +19,7 @@ import java.io.PrintStream; import java.nio.charset.StandardCharsets; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; @@ -136,7 +141,7 @@ private CodeExecutionResult executePythonWithGraalPy(CodeExecutionRequest reques ByteArrayOutputStream stdoutBytes = new ByteArrayOutputStream(); ByteArrayOutputStream stderrBytes = new ByteArrayOutputStream(); ToolExecutor toolExecutor = request.getToolExecutor(); - ToolBridge toolBridge = new ToolBridge(toolExecutor, request.getUser()); + ToolBridge toolBridge = new ToolBridge(toolExecutor, request.getUser(), request.getParentCallId()); ProxyExecutable callTool = new ProxyExecutable() { @Override @@ -204,6 +209,11 @@ public Value call() { }); Value value = future.get(timeout, TimeUnit.MILLISECONDS); + CodeExecutionResult pending = toolBridge.pendingResult( + new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)); + if (pending != null) { + return pending; + } Value fallback = context.getBindings("python").getMember("__codeact_result"); String resolved = resolveValue(fallback); if (resolved == null) { @@ -220,11 +230,21 @@ public Value call() { } catch (IllegalArgumentException e) { return null; } catch (TimeoutException e) { + CodeExecutionResult pending = toolBridge.pendingResult( + new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)); + if (pending != null) { + return pending; + } return CodeExecutionResult.builder() .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)) .error("code execution timeout") .build(); } catch (ExecutionException e) { + CodeExecutionResult pending = toolBridge.pendingResult( + new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)); + if (pending != null) { + return pending; + } Throwable cause = e.getCause() == null ? e : e.getCause(); log.warn("GraalPy execution failed", cause); return CodeExecutionResult.builder() @@ -232,6 +252,11 @@ public Value call() { .error(String.valueOf(cause.getMessage())) .build(); } catch (Throwable t) { + CodeExecutionResult pending = toolBridge.pendingResult( + new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)); + if (pending != null) { + return pending; + } log.warn("GraalPy execution failed", t); return CodeExecutionResult.builder() .stdout(new String(stdoutBytes.toByteArray(), StandardCharsets.UTF_8)) @@ -307,10 +332,14 @@ private String filterPolyglotWarnings(String stderr) { private static class ToolBridge { private final ToolExecutor toolExecutor; private final String user; + private final String parentCallId; + private int invocation; + private AgentToolResult pendingResult; - private ToolBridge(ToolExecutor toolExecutor, String user) { + private ToolBridge(ToolExecutor toolExecutor, String user, String parentCallId) { this.toolExecutor = toolExecutor; this.user = user; + this.parentCallId = parentCallId; } @HostAccess.Export @@ -329,8 +358,64 @@ public String call(String name, Object args) throws Exception { AgentToolCall call = AgentToolCall.builder() .name(resolveName(name)) .arguments(arguments) + .callId(nextCallId()) + .metadata(parentMetadata()) .build(); - return toolExecutor.execute(call); + AgentToolExecution execution = AsyncToolExecutors.start(toolExecutor, call); + if (execution == null) { + return null; + } + if (execution.isPending()) { + pendingResult = normalize(call, execution.getInitialResult()); + throw new CodeActPendingToolException(call.getCallId()); + } + AgentToolResult result = execution.await(); + return result == null ? null : result.getOutput(); + } + + private AgentToolResult normalize(AgentToolCall call, AgentToolResult source) { + AgentToolResult result = source == null ? new AgentToolResult() : source; + if (result.getName() == null) { + result.setName(call.getName()); + } + if (result.getCallId() == null) { + result.setCallId(call.getCallId()); + } + if (result.getStatus() == null) { + result.setStatus(AgentToolExecutionStatus.WAITING); + } + return result; + } + + private CodeExecutionResult pendingResult(String stdout) { + if (pendingResult == null) { + return null; + } + return CodeExecutionResult.builder() + .stdout(stdout) + .result(pendingResult.getOutput()) + .error(pendingResult.getError()) + .status(AgentToolExecutionStatus.WAITING) + .operationId(pendingResult.getOperationId()) + .waitId(pendingResult.getWaitId()) + .pendingToolCallId(pendingResult.getCallId()) + .parentCallId(parentCallId) + .build(); + } + + private String nextCallId() { + String suffix = String.valueOf(invocation++); + return parentCallId == null || parentCallId.trim().isEmpty() + ? "codeact_tool_" + suffix + : parentCallId + ":tool:" + suffix; + } + + private Map parentMetadata() { + Map metadata = new LinkedHashMap(); + if (parentCallId != null && !parentCallId.trim().isEmpty()) { + metadata.put(AgentToolCall.METADATA_KEY_PARENT_CALL_ID, parentCallId); + } + return metadata; } private String resolveName(String name) { diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/NashornCodeExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/NashornCodeExecutor.java index 162cf3d7..64c08db8 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/NashornCodeExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/codeact/NashornCodeExecutor.java @@ -1,6 +1,10 @@ package io.github.lnyocly.ai4j.agent.codeact; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,6 +17,8 @@ import java.io.StringWriter; import java.lang.reflect.Method; import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -69,7 +75,8 @@ private CodeExecutionResult executeJavaScript(CodeExecutionRequest request) { context.setWriter(stdout); context.setErrorWriter(stderr); Bindings bindings = engine.createBindings(); - bindings.put("__toolBridge", new ToolBridge(request.getToolExecutor(), request.getUser())); + ToolBridge toolBridge = new ToolBridge(request.getToolExecutor(), request.getUser(), request.getParentCallId()); + bindings.put("__toolBridge", toolBridge); context.setBindings(bindings, ScriptContext.ENGINE_SCOPE); String script = buildPrelude(request.getToolNames()) + "\n" + wrapCode(request.getCode()); @@ -86,6 +93,10 @@ public Object call() throws Exception { }); Object value = future.get(timeout, TimeUnit.MILLISECONDS); + CodeExecutionResult pending = toolBridge.pendingResult(stdout.toString()); + if (pending != null) { + return pending; + } Object resultValue = bindings.get("__codeact_result"); if (resultValue == null) { resultValue = value; @@ -98,11 +109,19 @@ public Object call() throws Exception { .error(error) .build(); } catch (TimeoutException e) { + CodeExecutionResult pending = toolBridge.pendingResult(stdout.toString()); + if (pending != null) { + return pending; + } return CodeExecutionResult.builder() .stdout(stdout.toString()) .error("code execution timeout") .build(); } catch (ExecutionException e) { + CodeExecutionResult pending = toolBridge.pendingResult(stdout.toString()); + if (pending != null) { + return pending; + } Throwable cause = e.getCause() == null ? e : e.getCause(); log.warn("Nashorn execution failed", cause); return CodeExecutionResult.builder() @@ -110,6 +129,10 @@ public Object call() throws Exception { .error(String.valueOf(cause.getMessage())) .build(); } catch (Throwable t) { + CodeExecutionResult pending = toolBridge.pendingResult(stdout.toString()); + if (pending != null) { + return pending; + } log.warn("Nashorn execution failed", t); return CodeExecutionResult.builder() .stdout(stdout.toString()) @@ -231,10 +254,14 @@ private ScriptEngine createNashornEngineWithOptions(String[] args) { public static class ToolBridge { private final ToolExecutor toolExecutor; private final String user; + private final String parentCallId; + private int invocation; + private AgentToolResult pendingResult; - private ToolBridge(ToolExecutor toolExecutor, String user) { + private ToolBridge(ToolExecutor toolExecutor, String user, String parentCallId) { this.toolExecutor = toolExecutor; this.user = user; + this.parentCallId = parentCallId; } public String call(String name, String arguments) throws Exception { @@ -245,8 +272,64 @@ public String call(String name, String arguments) throws Exception { AgentToolCall call = AgentToolCall.builder() .name(resolveName(name)) .arguments(payload) + .callId(nextCallId()) + .metadata(parentMetadata()) .build(); - return toolExecutor.execute(call); + AgentToolExecution execution = AsyncToolExecutors.start(toolExecutor, call); + if (execution == null) { + return null; + } + if (execution.isPending()) { + pendingResult = normalize(call, execution.getInitialResult()); + throw new CodeActPendingToolException(call.getCallId()); + } + AgentToolResult result = execution.await(); + return result == null ? null : result.getOutput(); + } + + private AgentToolResult normalize(AgentToolCall call, AgentToolResult source) { + AgentToolResult result = source == null ? new AgentToolResult() : source; + if (result.getName() == null) { + result.setName(call.getName()); + } + if (result.getCallId() == null) { + result.setCallId(call.getCallId()); + } + if (result.getStatus() == null) { + result.setStatus(AgentToolExecutionStatus.WAITING); + } + return result; + } + + private CodeExecutionResult pendingResult(String stdout) { + if (pendingResult == null) { + return null; + } + return CodeExecutionResult.builder() + .stdout(stdout) + .result(pendingResult.getOutput()) + .error(pendingResult.getError()) + .status(AgentToolExecutionStatus.WAITING) + .operationId(pendingResult.getOperationId()) + .waitId(pendingResult.getWaitId()) + .pendingToolCallId(pendingResult.getCallId()) + .parentCallId(parentCallId) + .build(); + } + + private String nextCallId() { + String suffix = String.valueOf(invocation++); + return parentCallId == null || parentCallId.trim().isEmpty() + ? "codeact_tool_" + suffix + : parentCallId + ":tool:" + suffix; + } + + private Map parentMetadata() { + Map metadata = new LinkedHashMap(); + if (parentCallId != null && !parentCallId.trim().isEmpty()) { + metadata.put(AgentToolCall.METADATA_KEY_PARENT_CALL_ID, parentCallId); + } + return metadata; } private String resolveName(String name) { diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/control/HostInputToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/control/HostInputToolExecutor.java index 5ff615f6..2bbc4654 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/control/HostInputToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/control/HostInputToolExecutor.java @@ -2,6 +2,11 @@ import com.alibaba.fastjson2.JSON; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.Collections; @@ -16,7 +21,7 @@ *

组装示例:{@code builder.toolExecutor(new HostInputToolExecutor(baseExecutor, * channel, Set.of("ask_user")))}。 */ -public class HostInputToolExecutor implements ToolExecutor { +public class HostInputToolExecutor implements AsyncToolExecutor { private final ToolExecutor delegate; private final AgentHostInputChannel channel; @@ -45,6 +50,25 @@ public String execute(AgentToolCall call) throws Exception { } } + /** + * Preserve asynchronous semantics for ordinary tools routed through this + * host-input boundary. The configured input channel remains deliberately + * blocking; applications that need durable user waits should use a tool + * that raises {@link AgentHostInputException} instead. + */ + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (call == null || call.getName() == null || !hostInputTools.contains(call.getName())) { + return AsyncToolExecutors.start(delegate, call); + } + return AgentToolExecution.completed(AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(execute(call)) + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + } + private static Map parseRequest(String arguments) { if (arguments == null || arguments.trim().isEmpty()) return Collections.emptyMap(); try { diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/dynamicworkflow/DynamicWorkflowHostToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/dynamicworkflow/DynamicWorkflowHostToolExecutor.java index c99c112a..92cfd661 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/dynamicworkflow/DynamicWorkflowHostToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/dynamicworkflow/DynamicWorkflowHostToolExecutor.java @@ -1,13 +1,21 @@ package io.github.lnyocly.ai4j.agent.dynamicworkflow; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CompletionException; + /** * Host-side adapter for extension tools that return a dynamic-workflow envelope. * Non-workflow tool outputs pass through unchanged. */ -public class DynamicWorkflowHostToolExecutor implements ToolExecutor { +public class DynamicWorkflowHostToolExecutor implements AsyncToolExecutor { private final ToolExecutor delegate; private final DynamicWorkflowExecutor executor; @@ -32,4 +40,70 @@ public String execute(AgentToolCall call) throws Exception { DynamicWorkflowExecutionResult result = executor.execute(DynamicWorkflowRequestParser.parse(output)); return result.toJson(); } + + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (delegate == null) { + throw new IllegalStateException("delegate tool executor is required"); + } + AgentToolExecution started = AsyncToolExecutors.start(delegate, call); + if (started == null) { + return AgentToolExecution.completed(result(call, null)); + } + AgentToolResult initial = started.isPending() + ? started.getInitialResult() : started.await(); + if (!started.isPending()) { + return AgentToolExecution.completed(transform(call, initial)); + } + CompletionStage completion = started.getCompletion(); + if (completion == null) { + return AgentToolExecution.of(transformPending(call, initial), null); + } + CompletionStage transformed = completion.thenApply(completed -> { + try { + return transform(call, completed); + } catch (Exception error) { + throw new CompletionException(error); + } + }); + return AgentToolExecution.of(transformPending(call, initial), transformed); + } + + private AgentToolResult transform(AgentToolCall call, AgentToolResult source) throws Exception { + AgentToolResult result = source == null ? result(call, null) : source; + if (result.getStatus() != null && !AgentToolExecutionStatus.COMPLETED.equals(result.getStatus())) { + return result; + } + String output = result.getOutput(); + if (!DynamicWorkflowRequestParser.isDynamicWorkflowEnvelope(output)) { + return result; + } + if (executor == null) { + throw new IllegalStateException("dynamic workflow executor is required"); + } + DynamicWorkflowExecutionResult workflowResult = executor.execute( + DynamicWorkflowRequestParser.parse(output)); + result.setOutput(workflowResult == null ? null : workflowResult.toJson()); + return result; + } + + private AgentToolResult transformPending(AgentToolCall call, AgentToolResult source) { + AgentToolResult result = source == null ? result(call, null) : source; + if (result.getName() == null && call != null) { + result.setName(call.getName()); + } + if (result.getCallId() == null && call != null) { + result.setCallId(call.getCallId()); + } + return result; + } + + private AgentToolResult result(AgentToolCall call, String output) { + return AgentToolResult.builder() + .name(call == null ? null : call.getName()) + .callId(call == null ? null : call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.COMPLETED) + .build(); + } } diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/extension/ExtensionGuardrailToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/extension/ExtensionGuardrailToolExecutor.java index 4d997993..31a522d6 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/extension/ExtensionGuardrailToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/extension/ExtensionGuardrailToolExecutor.java @@ -1,6 +1,9 @@ package io.github.lnyocly.ai4j.agent.extension; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.extension.ExtensionException; import io.github.lnyocly.ai4j.extension.guardrail.ExtensionGuardrail; @@ -13,7 +16,7 @@ import java.util.List; import java.util.Map; -public class ExtensionGuardrailToolExecutor implements ToolExecutor { +public class ExtensionGuardrailToolExecutor implements AsyncToolExecutor { public static final String ACTION_TOOL_EXECUTE = "tool.execute"; @@ -36,6 +39,12 @@ public String execute(AgentToolCall call) throws Exception { return delegate.execute(call); } + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + evaluate(call); + return AsyncToolExecutors.start(delegate, call); + } + private void evaluate(AgentToolCall call) { if (guardrails.isEmpty()) { return; diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/AgentMemory.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/AgentMemory.java index 2f4c3d0d..1301d3cc 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/AgentMemory.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/AgentMemory.java @@ -10,6 +10,15 @@ public interface AgentMemory { void addToolOutput(String callId, String output); + /** + * Replaces the result for an already-recorded tool call when a durable + * asynchronous operation completes. Legacy memories may return false and + * keep their historical append-only behavior. + */ + default boolean replaceToolOutput(String callId, String output) { + return false; + } + List getItems(); String getSummary(); diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/InMemoryAgentMemory.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/InMemoryAgentMemory.java index 21fe366f..f9f48fda 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/InMemoryAgentMemory.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/InMemoryAgentMemory.java @@ -5,6 +5,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; public class InMemoryAgentMemory implements AgentMemory { @@ -54,6 +55,29 @@ public void addToolOutput(String callId, String output) { maybeCompress(); } + @Override + public boolean replaceToolOutput(String callId, String output) { + if (callId == null) { + return false; + } + for (Object item : items) { + if (!(item instanceof Map)) { + continue; + } + Map candidate = (Map) item; + if (!"function_call_output".equals(String.valueOf(candidate.get("type"))) + || !callId.equals(String.valueOf(candidate.get("call_id")))) { + continue; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) item; + mutable.put("output", output); + maybeCompress(); + return true; + } + return false; + } + @Override public List getItems() { if (summary == null || summary.trim().isEmpty()) { diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemory.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemory.java index efb3ee49..89c01570 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemory.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/memory/JdbcAgentMemory.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; public class JdbcAgentMemory implements AgentMemory { @@ -131,6 +132,31 @@ public synchronized void addToolOutput(String callId, String output) { replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary()))); } + @Override + public synchronized boolean replaceToolOutput(String callId, String output) { + if (callId == null) { + return false; + } + MemorySnapshot snapshot = loadSnapshot(); + List items = copyItems(snapshot.getItems()); + for (Object item : items) { + if (!(item instanceof Map)) { + continue; + } + Map candidate = (Map) item; + if (!"function_call_output".equals(String.valueOf(candidate.get("type"))) + || !callId.equals(String.valueOf(candidate.get("call_id")))) { + continue; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) item; + mutable.put("output", output); + replaceSnapshot(applyCompressor(MemorySnapshot.from(items, snapshot.getSummary()))); + return true; + } + return false; + } + @Override public synchronized List getItems() { MemorySnapshot snapshot = loadSnapshot(); diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ChatModelClient.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ChatModelClient.java index 989aa1b6..004b15af 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ChatModelClient.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/model/ChatModelClient.java @@ -129,6 +129,10 @@ private ChatCompletion toChatCompletion(AgentPrompt prompt, boolean stream) { builder.toolChoice((String) prompt.getToolChoice()); } + if (prompt.getReasoning() instanceof String) { + builder.reasoningEffort((String) prompt.getReasoning()); + } + List tools = convertTools(prompt.getTools()); if (!tools.isEmpty()) { builder.tools(tools); diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/permission/AgentPermissionToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/permission/AgentPermissionToolExecutor.java index ba5d10f2..64246d14 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/permission/AgentPermissionToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/permission/AgentPermissionToolExecutor.java @@ -1,13 +1,17 @@ package io.github.lnyocly.ai4j.agent.permission; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; /** * Tool executor wrapper that evaluates an {@link AgentPermissionPolicy} before * delegating to the real executor. */ -public class AgentPermissionToolExecutor implements ToolExecutor { +public class AgentPermissionToolExecutor implements AsyncToolExecutor { private final ToolExecutor delegate; private final AgentPermissionPolicy policy; @@ -33,6 +37,20 @@ public AgentPermissionToolExecutor(ToolExecutor delegate, @Override public String execute(AgentToolCall call) throws Exception { + AgentToolExecution execution = start(call); + AgentToolResult result = execution == null ? null : execution.await(); + return result == null ? null : result.getOutput(); + } + + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + evaluate(call); + return AsyncToolExecutors.start(delegate, call); + } + + private void evaluate(AgentToolCall call) throws Exception { + boolean approvalGranted = call != null && call.hasMetadataValue( + AgentToolCall.METADATA_KEY_HARNESS_APPROVAL_GRANTED, Boolean.TRUE); AgentPermissionRequest request = AgentPermissionRequest.builder() .toolCall(call) .environment(environment) @@ -42,7 +60,10 @@ public String execute(AgentToolCall call) throws Exception { decision = AgentPermissionDecision.deny("permission policy returned no decision"); } if (decision.getType() == AgentPermissionDecisionType.ALLOW) { - return delegate.execute(call); + return; + } + if (decision.getType() == AgentPermissionDecisionType.REQUIRE_APPROVAL && approvalGranted) { + return; } if (decision.getType() == AgentPermissionDecisionType.REQUIRE_APPROVAL) { throw new AgentApprovalRequiredException(buildMessage("Tool requires approval", request, decision), request, decision); diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/replay/ResumableToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/replay/ResumableToolExecutor.java index 26fde514..9fcf5ba8 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/replay/ResumableToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/replay/ResumableToolExecutor.java @@ -1,8 +1,15 @@ package io.github.lnyocly.ai4j.agent.replay; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import java.util.concurrent.CompletionStage; + /** * Wraps a {@link ToolExecutor} with resume-or-capture semantics over a {@link ResumeCache}. * @@ -11,7 +18,7 @@ * crux of safe failure recovery: re-running a crashed task must not re-execute tools that already * took effect (file writes, API calls, charges). On a miss, delegates and records.

*/ -public class ResumableToolExecutor implements ToolExecutor { +public class ResumableToolExecutor implements AsyncToolExecutor { private final ToolExecutor delegate; private final ResumeCache cache; @@ -33,13 +40,55 @@ public ResumeCache getCache() { @Override public String execute(AgentToolCall call) throws Exception { + AgentToolExecution execution = start(call); + AgentToolResult result = execution == null ? null : execution.await(); + return result == null ? null : result.getOutput(); + } + + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { String key = ResumeCache.toolKey(call); String cached = cache.lookupTool(key); if (cached != null) { - return cached; + return AgentToolExecution.completed(result(call, cached)); + } + + AgentToolExecution started = AsyncToolExecutors.start(delegate, call); + if (started == null) { + return AgentToolExecution.completed(result(call, null)); + } + AgentToolResult initial = started.isPending() + ? started.getInitialResult() : started.await(); + if (!started.isPending()) { + recordCompleted(key, initial); + return AgentToolExecution.completed(initial); } - String output = delegate.execute(call); - cache.recordTool(key, output); - return output; + CompletionStage completion = started.getCompletion(); + if (completion == null) { + return AgentToolExecution.of(initial, null); + } + CompletionStage recorded = completion.thenApply(completed -> { + recordCompleted(key, completed); + return completed; + }); + return AgentToolExecution.of(initial, recorded); + } + + private void recordCompleted(String key, AgentToolResult result) { + if (result != null + && !AgentToolExecutionStatus.WAITING.equals(result.getStatus()) + && !AgentToolExecutionStatus.UNKNOWN.equals(result.getStatus()) + && result.getOutput() != null) { + cache.recordTool(key, result.getOutput()); + } + } + + private AgentToolResult result(AgentToolCall call, String output) { + return AgentToolResult.builder() + .name(call == null ? null : call.getName()) + .callId(call == null ? null : call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.COMPLETED) + .build(); } } diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java index d0783b29..66000e8c 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/BaseAgentRuntime.java @@ -3,6 +3,7 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.AgentContext; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; @@ -20,6 +21,8 @@ import io.github.lnyocly.ai4j.agent.subagent.HandoffPolicyException; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolCallSanitizer; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.agent.interceptor.ToolInterceptor; import io.github.lnyocly.ai4j.agent.interceptor.ToolCallDecision; @@ -39,10 +42,13 @@ import io.github.lnyocly.ai4j.extension.lifecycle.AgentLifecycleEventType; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -51,18 +57,35 @@ public abstract class BaseAgentRuntime implements io.github.lnyocly.ai4j.agent.AgentRuntime { - private volatile boolean cancelled = false; - private volatile Thread runningThread = null; + private final Set runningThreads = Collections.newSetFromMap( + new ConcurrentHashMap()); + private final Set cancelledThreads = Collections.newSetFromMap( + new ConcurrentHashMap()); @Override public void cancel() { - cancelled = true; - Thread t = runningThread; - if (t != null) { - t.interrupt(); + for (Thread thread : runningThreads) { + if (thread != null) { + cancelledThreads.add(thread); + thread.interrupt(); + } } } + /** Marks the current invocation as independently cancellable. */ + protected final void beginRun() { + Thread current = Thread.currentThread(); + cancelledThreads.remove(current); + runningThreads.add(current); + } + + /** Clears only the current invocation's lifecycle state. */ + protected final void finishRun() { + Thread current = Thread.currentThread(); + runningThreads.remove(current); + cancelledThreads.remove(current); + } + protected String runtimeName() { return "base"; } @@ -76,7 +99,7 @@ public AgentResult run(AgentContext context, AgentRequest request) throws Except try { return runInternal(context, request, null); } finally { - runningThread = null; + finishRun(); } } @@ -85,7 +108,7 @@ public void runStream(AgentContext context, AgentRequest request, AgentListener try { runInternal(context, request, listener); } finally { - runningThread = null; + finishRun(); } } @@ -94,7 +117,7 @@ public AgentResult runStreamResult(AgentContext context, AgentRequest request, A try { return runInternal(context, request, listener); } finally { - runningThread = null; + finishRun(); } } @@ -104,8 +127,7 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag int maxSteps = options == null ? AgentOptions.DEFAULT_MAX_STEPS : options.getMaxSteps(); long wallClockTimeoutMs = options == null ? AgentOptions.DEFAULT_WALL_CLOCK_TIMEOUT_MS : options.getWallClockTimeoutMillis(); long maxTokenBudget = options == null ? AgentOptions.UNLIMITED_TOKEN_BUDGET : options.getMaxTokenBudget(); - cancelled = false; - runningThread = Thread.currentThread(); + beginRun(); long startTime = System.currentTimeMillis(); boolean stream = listener != null && options != null && options.isStream(); String sessionId = request == null ? null : trimToNull(request.getMetadataString(AgentRequest.METADATA_KEY_SESSION_ID)); @@ -145,6 +167,7 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .turnId(turnId) .outputText("PROMPT_BLOCKED: " + reason) .steps(0) + .executionStatus(AgentExecutionStatus.COMPLETED) .build(); case MODIFY: effectiveInput = decision.getModifiedInput(); @@ -166,9 +189,7 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag while (!stepLimited || step < maxSteps) { throwIfInterrupted(); - if (cancelled) { - throw new InterruptedException("Agent run cancelled"); - } + throwIfCancelled(); if (wallClockTimeoutMs > 0 && System.currentTimeMillis() - startTime >= wallClockTimeoutMs) { throw new TimeoutException("Agent run exceeded wall-clock timeout of " + wallClockTimeoutMs + " ms"); } @@ -207,7 +228,8 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) - .steps(step + 1); + .steps(step + 1) + .executionStatus(AgentExecutionStatus.COMPLETED); return usage.applyTo(result, context).build(); } @@ -261,6 +283,22 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag dispatchLifecycle(context, AgentLifecycleEventType.AFTER_TURN, step, runtimeName(), modelResult); publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null, runId, sessionId, turnId); + AgentToolResult waitingResult = firstWaiting(executed); + if (waitingResult != null) { + AgentResult.AgentResultBuilder waiting = AgentResult.builder() + .runId(runId) + .sessionId(sessionId) + .turnId(turnId) + .outputText(modelResult == null ? "" : modelResult.getOutputText()) + .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) + .toolCalls(toolCalls) + .toolResults(toolResults) + .steps(step + 1) + .executionStatus(AgentExecutionStatus.WAITING) + .operationId(waitingResult.getOperationId()) + .waitId(waitingResult.getWaitId()); + return usage.applyTo(waiting, context).build(); + } step += 1; } @@ -273,10 +311,23 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .rawResponse(lastResult == null ? null : lastResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) - .steps(step); + .steps(step) + .executionStatus(AgentExecutionStatus.CONTINUATION_REQUIRED); return usage.applyTo(result, context).build(); } + private AgentToolResult firstWaiting(List results) { + if (results == null) { + return null; + } + for (AgentToolResult result : results) { + if (result != null && result.isWaiting()) { + return result; + } + } + return null; + } + /** * Checks if the CompactPolicy says the context is too large; if so, fires BEFORE_COMPACT, * runs the compaction, restores the memory, and fires ON_COMPACT. Called at the top of each @@ -310,12 +361,19 @@ private void autoCompactIfNecessary(AgentContext context, AgentListener listener } } - private void throwIfInterrupted() throws InterruptedException { + protected final void throwIfInterrupted() throws InterruptedException { if (Thread.currentThread().isInterrupted()) { throw new InterruptedException("Agent run interrupted"); } } + protected final void throwIfCancelled() throws InterruptedException { + throwIfInterrupted(); + if (cancelledThreads.contains(Thread.currentThread())) { + throw new InterruptedException("Agent run cancelled"); + } + } + private List normalizeToolCalls(List calls, int step) { List normalized = new ArrayList(); if (calls == null || calls.isEmpty()) { @@ -336,6 +394,8 @@ private List normalizeToolCalls(List calls, int st .name(trimToNull(call.getName()) == null ? "tool" : call.getName().trim()) .arguments(call.getArguments()) .type(call.getType()) + .metadata(call.getMetadata() == null + ? null : new LinkedHashMap(call.getMetadata())) .build()); index++; } @@ -521,6 +581,22 @@ protected String executeTool(AgentContext context, String runId, String sessionId, String turnId) throws Exception { + AgentToolResult result = executeToolResult(context, call, step, listener, runId, sessionId, turnId); + return result == null ? null : result.getOutput(); + } + + /** + * Executes a tool while preserving structured asynchronous state. The + * legacy string method above remains the compatibility surface used by + * older custom runtimes. + */ + protected AgentToolResult executeToolResult(AgentContext context, + AgentToolCall call, + Integer step, + AgentListener listener, + String runId, + String sessionId, + String turnId) throws Exception { ToolExecutor executor = context.getToolExecutor(); if (executor == null) { throw new IllegalStateException("toolExecutor is required"); @@ -534,12 +610,12 @@ protected String executeTool(AgentContext context, } switch (decision.getType()) { case BLOCK: - return buildBlockedOutput(call, decision.getReason()); + return result(call, buildBlockedOutput(call, decision.getReason()), AgentToolExecutionStatus.FAILED); case MODIFY: effectiveCall = decision.getModifiedCall(); break; case ROUTE_TO: - return routeToSandbox(context, call, decision); + return result(call, routeToSandbox(context, call, decision), AgentToolExecutionStatus.COMPLETED); case ALLOW: default: break; @@ -548,25 +624,26 @@ protected String executeTool(AgentContext context, final AgentToolCall callToRun = effectiveCall; try { dispatchLifecycle(context, AgentLifecycleEventType.BEFORE_TOOL_CALL, step == null ? 0 : step, callToRun == null ? null : callToRun.getName(), callToRun); - String output = AgentToolExecutionScope.runWithEmitter(new AgentToolExecutionScope.EventEmitter() { + AgentToolResult toolResult = AgentToolExecutionScope.runWithEmitter(new AgentToolExecutionScope.EventEmitter() { @Override public void emit(AgentEventType type, String message, Object payload) { publish(context, listener, type, step == null ? 0 : step, message, payload, runId, sessionId, turnId); } - }, new AgentToolExecutionScope.ScopeCallable() { + }, new AgentToolExecutionScope.ScopeCallable() { @Override - public String call() throws Exception { - return executor.execute(callToRun); + public AgentToolResult call() throws Exception { + return executeToolInvocation(executor, callToRun); } }); + toolResult = normalizeToolResult(callToRun, toolResult); // PostToolUse interception: a hook may veto the result (e.g. output leaked a secret). if (interceptor != null) { - ToolCallDecision after = interceptor.afterToolCall(callToRun, output, context); + ToolCallDecision after = interceptor.afterToolCall(callToRun, toolResult.getOutput(), context); if (after != null && after.getType() == ToolCallDecision.Type.BLOCK) { - return buildBlockedOutput(callToRun, after.getReason()); + return result(callToRun, buildBlockedOutput(callToRun, after.getReason()), AgentToolExecutionStatus.FAILED); } } - return output; + return toolResult; } catch (InterruptedException interruptedException) { Thread.currentThread().interrupt(); throw interruptedException; @@ -576,12 +653,57 @@ public String call() throws Exception { // #262: 宿主介入(审批/用户输入)必须中断循环并抛给调用方,禁止降级为 TOOL_ERROR。 throw controlFlowException; } catch (Exception ex) { - return buildToolErrorOutput(callToRun, ex); + return result(callToRun, buildToolErrorOutput(callToRun, ex), AgentToolExecutionStatus.FAILED); } finally { dispatchLifecycle(context, AgentLifecycleEventType.AFTER_TOOL_CALL, step == null ? 0 : step, callToRun == null ? null : callToRun.getName(), callToRun); } } + private AgentToolResult executeToolInvocation(ToolExecutor executor, + AgentToolCall call) throws Exception { + if (executor instanceof io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor) { + AgentToolExecution execution = ((io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor) executor).start(call); + if (execution == null) { + return result(call, null, AgentToolExecutionStatus.COMPLETED); + } + AgentToolResult initial = execution.isPending() + ? execution.getInitialResult() + : execution.await(); + return normalizeToolResult(call, initial); + } + return result(call, executor.execute(call), AgentToolExecutionStatus.COMPLETED); + } + + private AgentToolResult normalizeToolResult(AgentToolCall call, AgentToolResult source) { + AgentToolResult result = source == null ? new AgentToolResult() : source; + if (result.getName() == null && call != null) { + result.setName(call.getName()); + } + if (result.getCallId() == null && call != null) { + result.setCallId(call.getCallId()); + } + if (result.getStatus() == null) { + result.setStatus(result.getOutput() != null && result.getOutput().startsWith("TOOL_ERROR") + ? AgentToolExecutionStatus.FAILED + : AgentToolExecutionStatus.COMPLETED); + } + if (result.isFailed() && result.getOk() == null) { + result.setOk(Boolean.FALSE); + } + return result; + } + + private AgentToolResult result(AgentToolCall call, String output, AgentToolExecutionStatus status) { + return AgentToolResult.builder() + .name(call == null ? null : call.getName()) + .callId(call == null ? null : call.getCallId()) + .output(output) + .status(status) + .ok(AgentToolExecutionStatus.FAILED.equals(status) ? Boolean.FALSE : null) + .error(AgentToolExecutionStatus.FAILED.equals(status) ? output : null) + .build(); + } + protected String buildBlockedOutput(AgentToolCall call, String reason) { JSONObject payload = new JSONObject(); payload.put("blocked", true); @@ -720,18 +842,23 @@ private AgentToolResult runToolAndCaptureTrace(AgentContext context, String runId, String sessionId, String turnId) throws Exception { - String output = executeTool(context, call, step, listener, runId, sessionId, turnId); + AgentToolResult toolResult = executeToolResult(context, call, step, listener, runId, sessionId, turnId); Object trace = toolTrace(context); - AgentToolResult.AgentToolResultBuilder builder = AgentToolResult.builder() - .name(call.getName()) - .callId(call.getCallId()) - .output(output) - .trace(trace); + if (toolResult == null) { + toolResult = result(call, null, AgentToolExecutionStatus.COMPLETED); + } + toolResult.setName(call.getName()); + toolResult.setCallId(call.getCallId()); + if (trace != null) { + toolResult.setTrace(trace); + } // #264: 运行时 TOOL_ERROR 输出带上 ok=false,供 AgentTraceListener 标 ERROR - if (output != null && output.startsWith("TOOL_ERROR")) { - builder.ok(Boolean.FALSE).error(output); + if (toolResult.getOutput() != null && toolResult.getOutput().startsWith("TOOL_ERROR")) { + toolResult.setOk(Boolean.FALSE); + toolResult.setError(toolResult.getOutput()); + toolResult.setStatus(AgentToolExecutionStatus.FAILED); } - return builder.build(); + return toolResult; } /** Returns the executor's last sub-trace (if it is a TraceableToolExecutor), else null. */ diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/CodeActRuntime.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/CodeActRuntime.java index 3e9aed98..1a5eb187 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/CodeActRuntime.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/runtime/CodeActRuntime.java @@ -3,6 +3,7 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.AgentContext; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; import io.github.lnyocly.ai4j.agent.AgentOptions; import io.github.lnyocly.ai4j.agent.AgentRequest; import io.github.lnyocly.ai4j.agent.AgentResult; @@ -18,13 +19,16 @@ import io.github.lnyocly.ai4j.agent.model.AgentPrompt; import io.github.lnyocly.ai4j.agent.skill.AgentSkillRuntimeSupport; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.agent.util.AgentInputItem; import io.github.lnyocly.ai4j.extension.lifecycle.AgentLifecycleEventType; import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; public class CodeActRuntime extends BaseAgentRuntime { @@ -38,12 +42,22 @@ protected String runtimeName() { @Override public AgentResult run(AgentContext context, AgentRequest request) throws Exception { - return runInternal(context, request, null); + beginRun(); + try { + return runInternal(context, request, null); + } finally { + finishRun(); + } } @Override public void runStream(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { - runInternal(context, request, listener); + beginRun(); + try { + runInternal(context, request, listener); + } finally { + finishRun(); + } } protected AgentResult runInternal(AgentContext context, AgentRequest request, AgentListener listener) throws Exception { @@ -87,6 +101,7 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag int step = 0; boolean stepLimited = maxSteps > 0; while (!stepLimited || step < maxSteps) { + throwIfCancelled(); publish(context, listener, AgentEventType.STEP_START, step, runtimeName(), null, runId, sessionId, turnId); dispatchLifecycle(context, AgentLifecycleEventType.BEFORE_TURN, step, runtimeName(), null); @@ -123,7 +138,8 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) - .steps(step + 1), context).build(); + .steps(step + 1) + .executionStatus(AgentExecutionStatus.COMPLETED), context).build(); } if (!"code".equals(message.type) || message.code == null) { @@ -138,7 +154,8 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) - .steps(step + 1), context).build(); + .steps(step + 1) + .executionStatus(AgentExecutionStatus.COMPLETED), context).build(); } AgentToolCall toolCall = AgentToolCall.builder() @@ -158,22 +175,38 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .toolNames(extractToolNames(context.getToolRegistry() == null ? null : context.getToolRegistry().getTools())) .toolExecutor(context.getToolExecutor()) .user(context.getUser()) + .parentCallId(toolCall.getCallId()) .build()); } finally { dispatchLifecycle(context, AgentLifecycleEventType.AFTER_TOOL_CALL, step, toolCall.getName(), toolCall); } String toolOutput = buildToolOutput(execResult); - toolResults.add(AgentToolResult.builder() - .name("code") - .callId(toolCall.getCallId()) - .output(toolOutput) - .build()); + AgentToolResult codeToolResult = buildCodeToolResult(toolCall, execResult, toolOutput); + toolResults.add(codeToolResult); + publish(context, listener, AgentEventType.TOOL_RESULT, step, toolOutput, codeToolResult, runId, sessionId, turnId); + if (execResult != null && execResult.isWaiting()) { + memory.addOutputItems(java.util.Collections.singletonList( + AgentInputItem.systemMessage(pendingMarker(toolCall, toolOutput)))); + dispatchLifecycle(context, AgentLifecycleEventType.AFTER_TURN, step, runtimeName(), modelResult); + publish(context, listener, AgentEventType.STEP_END, step, runtimeName(), null, runId, sessionId, turnId); + return usage.applyTo(AgentResult.builder() + .runId(runId) + .sessionId(sessionId) + .turnId(turnId) + .outputText(output == null ? "" : output) + .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) + .toolCalls(toolCalls) + .toolResults(toolResults) + .steps(step + 1) + .executionStatus(AgentExecutionStatus.WAITING) + .operationId(execResult.getOperationId()) + .waitId(execResult.getWaitId()), context).build(); + } String toolMessage = (execResult != null && execResult.isSuccess()) ? "CODE_RESULT: " + toolOutput : "CODE_ERROR: " + toolOutput; memory.addOutputItems(java.util.Collections.singletonList(AgentInputItem.systemMessage(toolMessage))); - publish(context, listener, AgentEventType.TOOL_RESULT, step, toolOutput, execResult, runId, sessionId, turnId); if (reAct) { finalizeRequested = execResult != null && execResult.isSuccess(); } @@ -193,7 +226,8 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .rawResponse(modelResult == null ? null : modelResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) - .steps(step + 1), context).build(); + .steps(step + 1) + .executionStatus(AgentExecutionStatus.COMPLETED), context).build(); } dispatchLifecycle(context, AgentLifecycleEventType.AFTER_TURN, step, runtimeName(), modelResult); @@ -210,7 +244,15 @@ protected AgentResult runInternal(AgentContext context, AgentRequest request, Ag .rawResponse(lastResult == null ? null : lastResult.getRawResponse()) .toolCalls(toolCalls) .toolResults(toolResults) - .steps(step), context).build(); + .steps(step) + .executionStatus(AgentExecutionStatus.CONTINUATION_REQUIRED), context).build(); + } + + private String pendingMarker(AgentToolCall call, String toolOutput) { + Map marker = new LinkedHashMap(); + marker.put("callId", call == null ? null : call.getCallId()); + marker.put("output", toolOutput); + return AgentToolCall.CODEACT_PENDING_RESULT_PREFIX + " " + JSON.toJSONString(marker); } @Override @@ -428,9 +470,37 @@ private String buildToolOutput(CodeExecutionResult result) { if (result.getError() != null && !result.getError().isEmpty()) { obj.put("error", result.getError()); } + if (result.getStatus() != null && result.getStatus() != AgentToolExecutionStatus.COMPLETED) { + obj.put("status", result.getStatus().name()); + } + if (result.getOperationId() != null) { + obj.put("operationId", result.getOperationId()); + } + if (result.getWaitId() != null) { + obj.put("waitId", result.getWaitId()); + } return obj.toJSONString(); } + private AgentToolResult buildCodeToolResult(AgentToolCall call, + CodeExecutionResult result, + String output) { + AgentToolExecutionStatus status = result == null || result.getStatus() == null + ? (result != null && result.isSuccess() + ? AgentToolExecutionStatus.COMPLETED : AgentToolExecutionStatus.FAILED) + : result.getStatus(); + return AgentToolResult.builder() + .name(call == null ? "code" : call.getName()) + .callId(call == null ? null : call.getCallId()) + .output(output) + .status(status) + .ok(AgentToolExecutionStatus.FAILED.equals(status) ? Boolean.FALSE : null) + .error(result == null ? "code execution returned no result" : result.getError()) + .operationId(result == null ? null : result.getOperationId()) + .waitId(result == null ? null : result.getWaitId()) + .build(); + } + private String resolveDirectOutput(CodeExecutionResult result) { if (result == null || !result.isSuccess()) { return null; diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/skill/AgentSkillRuntimeSupport.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/skill/AgentSkillRuntimeSupport.java index 66c9ca75..f9b1cfbc 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/skill/AgentSkillRuntimeSupport.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/skill/AgentSkillRuntimeSupport.java @@ -10,6 +10,9 @@ import io.github.lnyocly.ai4j.agent.permission.AgentPermissionToolExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry; import io.github.lnyocly.ai4j.agent.tool.RoutingToolExecutor; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; @@ -399,6 +402,7 @@ private static AgentToolCall renameSkillReaderCall(AgentToolCall call, String ex .arguments(call.getArguments()) .callId(call.getCallId()) .type(call.getType()) + .metadata(call.getMetadata() == null ? null : new LinkedHashMap(call.getMetadata())) .build(); } @@ -485,7 +489,7 @@ private static void validateSelectedSkillContent(SkillDescriptor skill, String c } /** Maps the public fallback name back before guardrail and permission evaluation. */ - private static final class SkillToolAliasExecutor implements ToolExecutor { + private static final class SkillToolAliasExecutor implements AsyncToolExecutor { private final String alias; private final ToolExecutor delegate; @@ -502,6 +506,14 @@ public String execute(AgentToolCall call) throws Exception { } return delegate.execute(canonicalSkillReaderCall(call, alias)); } + + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (call == null || !alias.equals(call.getName())) { + throw new IllegalArgumentException("Unsupported Skill tool: " + (call == null ? null : call.getName())); + } + return AsyncToolExecutors.start(delegate, canonicalSkillReaderCall(call, alias)); + } } /** @@ -533,6 +545,11 @@ public void addToolOutput(String callId, String output) { delegate.addToolOutput(callId, output); } + @Override + public boolean replaceToolOutput(String callId, String output) { + return delegate.replaceToolOutput(callId, output); + } + @Override public List getItems() { List items = new ArrayList(); diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentToolExecutor.java index 8a574bb4..82c3520a 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/subagent/SubAgentToolExecutor.java @@ -5,6 +5,11 @@ import io.github.lnyocly.ai4j.agent.event.AgentEventType; import io.github.lnyocly.ai4j.agent.runtime.AgentToolExecutionScope; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.LinkedHashMap; @@ -23,7 +28,7 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; -public class SubAgentToolExecutor implements ToolExecutor { +public class SubAgentToolExecutor implements AsyncToolExecutor { private static final AtomicInteger HANDOFF_THREAD_INDEX = new AtomicInteger(1); private static final ExecutorService HANDOFF_EXECUTOR = Executors.newCachedThreadPool(new ThreadFactory() { @@ -68,6 +73,34 @@ public String execute(AgentToolCall call) throws Exception { return delegate.execute(call); } + /** + * Sub-agent registry calls are currently synchronous, so their existing + * handoff behavior remains unchanged. A non-subagent delegate may already + * expose a pending operation; preserve that state instead of forcing the + * delegate through its blocking compatibility method. + */ + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (call == null) { + return AgentToolExecution.completed(AgentToolResult.builder() + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + } + String toolName = call.getName(); + if (subAgentRegistry != null && subAgentRegistry.supports(toolName)) { + return AgentToolExecution.completed(AgentToolResult.builder() + .name(toolName) + .callId(call.getCallId()) + .output(execute(call)) + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + } + if (delegate == null) { + throw new IllegalStateException("toolExecutor is required for non-subagent tool: " + toolName); + } + return AsyncToolExecutors.start(delegate, call); + } + private String executeSubAgent(AgentToolCall call, String toolName) throws Exception { if (!policy.isEnabled()) { return executeWithoutPolicy(call, toolName); diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolExecutor.java index 11ead723..7e90039a 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/team/tool/AgentTeamToolExecutor.java @@ -6,12 +6,17 @@ import io.github.lnyocly.ai4j.agent.team.AgentTeamMessage; import io.github.lnyocly.ai4j.agent.team.AgentTeamTaskState; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.ArrayList; import java.util.List; -public class AgentTeamToolExecutor implements ToolExecutor { +public class AgentTeamToolExecutor implements AsyncToolExecutor { private final AgentTeamControl control; private final String memberId; @@ -75,6 +80,29 @@ public String execute(AgentToolCall call) throws Exception { throw new IllegalStateException("unsupported team tool: " + toolName); } + /** Team-control operations are local and synchronous; preserve pending + * results from the application delegate for all other tools. */ + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (call == null) { + return AgentToolExecution.completed(AgentToolResult.builder() + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + } + if (AgentTeamToolRegistry.supports(call.getName())) { + return AgentToolExecution.completed(AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(execute(call)) + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + } + if (delegate == null) { + throw new IllegalStateException("toolExecutor is required for non-team tool: " + call.getName()); + } + return AsyncToolExecutors.start(delegate, call); + } + private String handleSendMessage(JSONObject args) { String toMemberId = firstString(args, "toMemberId", "to", "memberId"); String content = firstString(args, "content", "message", "text"); diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCall.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCall.java index 973c8f20..0801b73c 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCall.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolCall.java @@ -5,12 +5,26 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.LinkedHashMap; +import java.util.Map; + @Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentToolCall { + public static final String METADATA_KEY_HARNESS_APPROVAL_GRANTED = "harnessApprovalGranted"; + + /** Optional stable identity for a side-effecting tool invocation. */ + public static final String METADATA_KEY_HARNESS_INVOCATION_ID = "harnessInvocationId"; + + /** Identifies the outer runtime call that owns a nested tool invocation. */ + public static final String METADATA_KEY_PARENT_CALL_ID = "parentCallId"; + + /** Prefix for the portable system marker used by CodeAct pending calls. */ + public static final String CODEACT_PENDING_RESULT_PREFIX = "CODEACT_PENDING_TOOL_RESULT:"; + private String name; private String arguments; @@ -18,4 +32,31 @@ public class AgentToolCall { private String callId; private String type; + + /** Host/runtime metadata; providers do not need to serialize it. */ + private Map metadata; + + /** Source-compatible constructor retained for the original four-field shape. */ + public AgentToolCall(String name, String arguments, String callId, String type) { + this(name, arguments, callId, type, null); + } + + public AgentToolCall withMetadata(String key, Object value) { + Map values = metadata == null + ? new LinkedHashMap() + : new LinkedHashMap(metadata); + if (key != null) { + values.put(key, value); + } + this.metadata = values; + return this; + } + + public boolean hasMetadataValue(String key, Object expected) { + if (metadata == null || key == null) { + return false; + } + Object actual = metadata.get(key); + return expected == null ? actual == null : expected.equals(actual); + } } diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolExecution.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolExecution.java new file mode 100644 index 00000000..9f3c84b3 --- /dev/null +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolExecution.java @@ -0,0 +1,89 @@ +package io.github.lnyocly.ai4j.agent.tool; + +import java.util.concurrent.CompletionStage; + +/** + * Result of starting an asynchronous tool. + * + *

A pending execution carries a durable initial result immediately. Its + * completion stage is optional because a production host may persist the + * operation and deliver the completion after a process restart. Direct + * callers of the legacy synchronous {@code ToolExecutor} surface can still + * call {@link #await()} when a completion stage is available.

+ */ +public final class AgentToolExecution { + + private final AgentToolResult initialResult; + private final CompletionStage completion; + + private AgentToolExecution(AgentToolResult initialResult, + CompletionStage completion) { + this.initialResult = initialResult; + this.completion = completion; + } + + public static AgentToolExecution completed(AgentToolResult result) { + AgentToolResult normalized = result == null ? new AgentToolResult() : result; + if (normalized.getStatus() == null) { + normalized.setStatus(AgentToolExecutionStatus.COMPLETED); + } + return new AgentToolExecution(normalized, null); + } + + public static AgentToolExecution pending(String operationId, + String waitId, + String output) { + return pending(operationId, waitId, output, null, null); + } + + public static AgentToolExecution pending(String operationId, + String waitId, + String output, + Long retryAfterMillis, + CompletionStage completion) { + AgentToolResult result = AgentToolResult.builder() + .output(output == null ? "ASYNC_TOOL_WAITING" : output) + .status(AgentToolExecutionStatus.WAITING) + .operationId(operationId) + .waitId(waitId) + .retryAfterMillis(retryAfterMillis) + .build(); + return new AgentToolExecution(result, completion); + } + + public static AgentToolExecution of(AgentToolResult initialResult, + CompletionStage completion) { + AgentToolResult result = initialResult == null ? new AgentToolResult() : initialResult; + if (result.getStatus() == null) { + result.setStatus(completion == null + ? AgentToolExecutionStatus.COMPLETED + : AgentToolExecutionStatus.WAITING); + } + return new AgentToolExecution(result, completion); + } + + public AgentToolResult getInitialResult() { + return initialResult; + } + + public CompletionStage getCompletion() { + return completion; + } + + public boolean isPending() { + return initialResult != null && initialResult.isWaiting(); + } + + /** + * Waits for a completion when the tool exposed one. If the operation is + * deliberately durable and externally delivered, the pending result is + * returned instead of inventing a JVM-local completion. + */ + public AgentToolResult await() throws Exception { + if (!isPending() || completion == null) { + return initialResult; + } + AgentToolResult result = completion.toCompletableFuture().get(); + return result == null ? initialResult : result; + } +} diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolExecutionStatus.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolExecutionStatus.java new file mode 100644 index 00000000..27a5c346 --- /dev/null +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolExecutionStatus.java @@ -0,0 +1,13 @@ +package io.github.lnyocly.ai4j.agent.tool; + +/** + * Lifecycle state of one tool invocation. The value is deliberately separate + * from the string sent to a model so hosts can persist and route asynchronous + * work without parsing tool output. + */ +public enum AgentToolExecutionStatus { + COMPLETED, + WAITING, + FAILED, + UNKNOWN +} diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolResult.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolResult.java index 64e1f737..f8514556 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolResult.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AgentToolResult.java @@ -33,11 +33,45 @@ public class AgentToolResult { /** 失败原因(给人/trace 看);LLM 仍主要读 {@link #output}。 */ private String error; + /** Structured lifecycle state for asynchronous or recoverable tools. */ + private AgentToolExecutionStatus status; + + /** Stable operation identity returned by a long-running tool. */ + private String operationId; + + /** Harness or host wait identity associated with {@link #operationId}. */ + private String waitId; + + /** Optional retry hint supplied by the tool or host. */ + private Long retryAfterMillis; + + /** + * Source-compatible constructor retained for callers using the original + * result shape before asynchronous lifecycle fields were added. + */ + public AgentToolResult(String name, + String callId, + String output, + Object trace, + Boolean ok, + String error) { + this.name = name; + this.callId = callId; + this.output = output; + this.trace = trace; + this.ok = ok; + this.error = error; + } + /** * 是否应记为 TOOL span ERROR。 *

规则:{@code ok == false},或 {@code error} 非空,或 {@code output} 以 {@code TOOL_ERROR} 开头。 */ public boolean isFailed() { + if (AgentToolExecutionStatus.FAILED.equals(status) + || AgentToolExecutionStatus.UNKNOWN.equals(status)) { + return true; + } if (Boolean.FALSE.equals(ok)) { return true; } @@ -46,4 +80,8 @@ public boolean isFailed() { } return output != null && output.startsWith("TOOL_ERROR"); } + + public boolean isWaiting() { + return AgentToolExecutionStatus.WAITING.equals(status); + } } diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AsyncToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AsyncToolExecutor.java new file mode 100644 index 00000000..feb3cbaa --- /dev/null +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AsyncToolExecutor.java @@ -0,0 +1,23 @@ +package io.github.lnyocly.ai4j.agent.tool; + +/** + * Optional asynchronous extension to the legacy synchronous tool contract. + * Implementations start work and return a durable pending handle immediately; + * the Agent runtime will stop at that tool boundary instead of blocking the + * whole Agent run on a remote operation. + */ +public interface AsyncToolExecutor extends ToolExecutor { + + AgentToolExecution start(AgentToolCall call) throws Exception; + + /** + * Preserve the old ToolExecutor API for callers that explicitly want a + * blocking call. Harness-aware runtimes use {@link #start(AgentToolCall)}. + */ + @Override + default String execute(AgentToolCall call) throws Exception { + AgentToolExecution execution = start(call); + AgentToolResult result = execution == null ? null : execution.await(); + return result == null ? null : result.getOutput(); + } +} diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AsyncToolExecutors.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AsyncToolExecutors.java new file mode 100644 index 00000000..1c84a23e --- /dev/null +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/AsyncToolExecutors.java @@ -0,0 +1,36 @@ +package io.github.lnyocly.ai4j.agent.tool; + +/** Utilities for preserving asynchronous semantics through transparent tool decorators. */ +public final class AsyncToolExecutors { + + private AsyncToolExecutors() { + } + + public static AgentToolExecution start(ToolExecutor executor, AgentToolCall call) throws Exception { + if (executor == null) { + throw new IllegalArgumentException("toolExecutor is required"); + } + if (executor instanceof AsyncToolExecutor) { + return ((AsyncToolExecutor) executor).start(call); + } + String output = executor.execute(call); + return AgentToolExecution.completed(AgentToolResult.builder() + .name(call == null ? null : call.getName()) + .callId(call == null ? null : call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + } + + public static AgentToolResult await(ToolExecutor executor, AgentToolCall call) throws Exception { + AgentToolExecution execution = start(executor, call); + if (execution == null) { + return AgentToolResult.builder() + .name(call == null ? null : call.getName()) + .callId(call == null ? null : call.getCallId()) + .status(AgentToolExecutionStatus.COMPLETED) + .build(); + } + return execution.await(); + } +} diff --git a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/RoutingToolExecutor.java b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/RoutingToolExecutor.java index c14db3d1..3787b953 100644 --- a/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/RoutingToolExecutor.java +++ b/ai4j-agent/src/main/java/io/github/lnyocly/ai4j/agent/tool/RoutingToolExecutor.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.Set; -public class RoutingToolExecutor implements ToolExecutor { +public class RoutingToolExecutor implements AsyncToolExecutor { private final List routes; private final ToolExecutor fallbackExecutor; @@ -38,6 +38,27 @@ public String execute(AgentToolCall call) throws Exception { throw new IllegalArgumentException("No tool executor found for tool: " + toolName); } + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + ToolExecutor executor = resolve(call == null ? null : call.getName()); + return AsyncToolExecutors.start(executor, call); + } + + private ToolExecutor resolve(String toolName) { + for (Route route : routes) { + if (route != null && route.supports(toolName)) { + if (route.getExecutor() == null) { + break; + } + return route.getExecutor(); + } + } + if (fallbackExecutor != null) { + return fallbackExecutor; + } + throw new IllegalArgumentException("No tool executor found for tool: " + toolName); + } + public static Route route(Set toolNames, ToolExecutor executor) { return new Route(toolNames, executor); } diff --git a/ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentApprovalPermissionPolicyTest.java b/ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentApprovalPermissionPolicyTest.java index 68d735df..6f03ffef 100644 --- a/ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentApprovalPermissionPolicyTest.java +++ b/ai4j-agent/src/test/java/io/github/lnyocly/agent/AgentApprovalPermissionPolicyTest.java @@ -64,6 +64,24 @@ public void shouldBlockDeniedToolBeforeDelegateRuns() throws Exception { Assert.assertEquals(0, delegate.count); } + @Test + public void harnessApprovalCannotOverrideDenyPolicy() throws Exception { + CountingToolExecutor delegate = new CountingToolExecutor(); + AgentPermissionToolExecutor executor = new AgentPermissionToolExecutor( + delegate, + AgentPermissionPolicies.denyTools(Collections.singleton("bash"), "shell is forbidden"), + AgentExecutionEnvironment.LOCAL); + + try { + executor.execute(call("bash").withMetadata( + AgentToolCall.METADATA_KEY_HARNESS_APPROVAL_GRANTED, Boolean.TRUE)); + Assert.fail("Harness approval must not override an explicit DENY policy"); + } catch (AgentPermissionException expected) { + Assert.assertTrue(expected.getMessage().contains("shell is forbidden")); + } + Assert.assertEquals(0, delegate.count); + } + @Test public void shouldBlockApprovalRequiredToolBeforeDelegateRuns() throws Exception { CountingToolExecutor delegate = new CountingToolExecutor(); diff --git a/ai4j-bom/pom.xml b/ai4j-bom/pom.xml index d3fc7f89..ec4612cd 100644 --- a/ai4j-bom/pom.xml +++ b/ai4j-bom/pom.xml @@ -62,6 +62,11 @@ ai4j-agent ${project.version} + + io.github.lnyo-cly + ai4j-harness + ${project.version} + io.github.lnyo-cly ai4j diff --git a/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpToolApprovalDecorator.java b/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpToolApprovalDecorator.java index 8fd364eb..866223fa 100644 --- a/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpToolApprovalDecorator.java +++ b/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/acp/AcpToolApprovalDecorator.java @@ -3,6 +3,8 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.runtime.CliToolApprovalDecorator; @@ -64,6 +66,22 @@ public String execute(AgentToolCall call) throws Exception { }; } + @Override + public ToolExecutor decorateAsync(final String toolName, final AsyncToolExecutor delegate) { + if (delegate == null || approvalMode == ApprovalMode.AUTO) { + return delegate; + } + return new AsyncToolExecutor() { + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (requiresApproval(toolName, call)) { + requestApproval(toolName, call); + } + return delegate.start(call); + } + }; + } + private boolean requiresApproval(String toolName, AgentToolCall call) { if (approvalMode == ApprovalMode.MANUAL) { return true; diff --git a/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliToolApprovalDecorator.java b/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliToolApprovalDecorator.java index 73cdfd1d..5aac46ec 100644 --- a/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliToolApprovalDecorator.java +++ b/ai4j-cli/src/main/java/io/github/lnyocly/ai4j/cli/runtime/CliToolApprovalDecorator.java @@ -3,6 +3,8 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import io.github.lnyocly.ai4j.cli.ApprovalMode; import io.github.lnyocly.ai4j.cli.render.CodexStyleBlockFormatter; @@ -52,6 +54,22 @@ public String execute(AgentToolCall call) throws Exception { }; } + @Override + public ToolExecutor decorateAsync(final String toolName, final AsyncToolExecutor delegate) { + if (delegate == null || approvalMode == ApprovalMode.AUTO) { + return delegate; + } + return new AsyncToolExecutor() { + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (requiresApproval(toolName, call)) { + requestApproval(toolName, call); + } + return delegate.start(call); + } + }; + } + private boolean requiresApproval(String toolName, AgentToolCall call) { if (approvalMode == ApprovalMode.MANUAL) { return true; diff --git a/ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandTest.java b/ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandTest.java index a45b0efa..55b27f14 100644 --- a/ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandTest.java +++ b/ai4j-cli/src/test/java/io/github/lnyocly/ai4j/cli/CodeCommandTest.java @@ -333,32 +333,39 @@ public void test_skills_command_lists_discovered_workspace_skills() throws Excep "Review repository changes safely." ), StandardCharsets.UTF_8); - ByteArrayInputStream input = new ByteArrayInputStream( - ("/skills\n" - + "/exit\n").getBytes(StandardCharsets.UTF_8) - ); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); + String originalUserHome = System.getProperty("user.home"); + System.setProperty("user.home", workspace.resolve("isolated-home").toString()); + try { - CodeCommand command = new CodeCommand( - new FakeCodingCliAgentFactory(), - Collections.emptyMap(), - new Properties(), - workspace - ); + ByteArrayInputStream input = new ByteArrayInputStream( + ("/skills\n" + + "/exit\n").getBytes(StandardCharsets.UTF_8) + ); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); - int exitCode = command.run( - Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), - new StreamsTerminalIO(input, out, err) - ); + CodeCommand command = new CodeCommand( + new FakeCodingCliAgentFactory(), + Collections.emptyMap(), + new Properties(), + workspace + ); - String output = new String(out.toByteArray(), StandardCharsets.UTF_8); - Assert.assertEquals(0, exitCode); - Assert.assertTrue(output.contains("skills:")); - Assert.assertTrue(output.contains("count=1")); - Assert.assertTrue(output.contains("repo-review")); - Assert.assertTrue(output.contains("source=workspace")); - Assert.assertTrue(output.contains("Review repository changes safely.")); + int exitCode = command.run( + Arrays.asList("--model", "fake-model", "--workspace", workspace.toString()), + new StreamsTerminalIO(input, out, err) + ); + + String output = new String(out.toByteArray(), StandardCharsets.UTF_8); + Assert.assertEquals(0, exitCode); + Assert.assertTrue(output.contains("skills:")); + Assert.assertTrue(output.contains("count=1")); + Assert.assertTrue(output.contains("repo-review")); + Assert.assertTrue(output.contains("source=workspace")); + Assert.assertTrue(output.contains("Review repository changes safely.")); + } finally { + restoreProperty("user.home", originalUserHome); + } } @Test diff --git a/ai4j-coding/pom.xml b/ai4j-coding/pom.xml index 2d8cfbe6..d4d7dede 100644 --- a/ai4j-coding/pom.xml +++ b/ai4j-coding/pom.xml @@ -60,6 +60,11 @@ ai4j-agent ${project.version} + + io.github.lnyo-cly + ai4j-harness + ${project.version} + io.github.lnyo-cly ai4j-extension-api diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilder.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilder.java index ec3380df..b53fb4d7 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilder.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentBuilder.java @@ -14,6 +14,7 @@ import io.github.lnyocly.ai4j.agent.extension.ExtensionAgentTools; import io.github.lnyocly.ai4j.agent.extension.ExtensionGuardrailToolExecutor; import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; import io.github.lnyocly.ai4j.agent.tool.CompositeToolRegistry; import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; @@ -58,6 +59,7 @@ import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -445,8 +447,10 @@ public static ToolExecutor createBuiltInToolExecutor(WorkspaceContext workspaceC decorate(CodingToolNames.WRITE_FILE, new WriteFileToolExecutor(workspaceContext), decorator))); routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.APPLY_PATCH), decorate(CodingToolNames.APPLY_PATCH, new ApplyPatchToolExecutor(workspaceContext), decorator))); - routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.BASH), - decorate(CodingToolNames.BASH, new BashToolExecutor(workspaceContext, resolvedOptions, processRegistry, shellCommandExecutor), decorator))); + routes.add(RoutingToolExecutor.route(new HashSet(Arrays.asList( + CodingToolNames.BASH, CodingToolNames.BASH_PROCESS)), + decorate(CodingToolNames.BASH, + new BashToolExecutor(workspaceContext, resolvedOptions, processRegistry, shellCommandExecutor), decorator))); routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.GLOB), decorate(CodingToolNames.GLOB, new GlobToolExecutor(workspaceContext), decorator))); routes.add(RoutingToolExecutor.route(Collections.singleton(CodingToolNames.GREP), @@ -584,6 +588,9 @@ private static ToolExecutor decorate(String toolName, ToolExecutor executor, Too if (decorator == null || executor == null) { return executor; } + if (executor instanceof AsyncToolExecutor) { + return decorator.decorateAsync(toolName, (AsyncToolExecutor) executor); + } return decorator.decorate(toolName, executor); } diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentHarness.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentHarness.java new file mode 100644 index 00000000..89d62d86 --- /dev/null +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentHarness.java @@ -0,0 +1,180 @@ +package io.github.lnyocly.ai4j.coding; + +import io.github.lnyocly.ai4j.harness.AgentHarness; +import io.github.lnyocly.ai4j.harness.HarnessActor; +import io.github.lnyocly.ai4j.harness.HarnessCommandGateway; +import io.github.lnyocly.ai4j.harness.HarnessContract; +import io.github.lnyocly.ai4j.harness.HarnessPersistence; +import io.github.lnyocly.ai4j.harness.HarnessRunBudget; +import io.github.lnyocly.ai4j.harness.HarnessRunListener; +import io.github.lnyocly.ai4j.harness.HarnessRunRequest; +import io.github.lnyocly.ai4j.harness.HarnessRunResult; +import io.github.lnyocly.ai4j.harness.HarnessStore; + +import javax.sql.DataSource; +import java.nio.file.Path; +import java.util.List; + +/** + * Convenient facade for running a {@link CodingAgent} inside a durable + * Harness. The generic {@link AgentHarness} remains available when an + * application needs direct access to the adapter protocol or Gateway. + */ +public final class CodingAgentHarness implements AutoCloseable { + + private final AgentHarness harness; + + private CodingAgentHarness(AgentHarness harness) { + this.harness = harness; + } + + public static CodingAgentHarness file(Path directory, CodingAgent codingAgent) { + return builder() + .codingAgent(codingAgent) + .persistence(HarnessPersistence.file(directory)) + .build(); + } + + public static CodingAgentHarness file(Path directory, + CodingAgent codingAgent, + HarnessContract contract) { + return builder() + .codingAgent(codingAgent) + .contract(contract) + .persistence(HarnessPersistence.file(directory)) + .build(); + } + + public static CodingAgentHarness jdbc(DataSource dataSource, + String harnessId, + CodingAgent codingAgent) { + return builder() + .codingAgent(codingAgent) + .persistence(HarnessPersistence.jdbc(dataSource, harnessId)) + .build(); + } + + public static Builder builder() { + return new Builder(); + } + + public CodingAgent getCodingAgent() { + return ((CodingAgentHarnessExecutionAdapter) harness.getExecutionAdapter()).getCodingAgent(); + } + + public AgentHarness getHarness() { + return harness; + } + + public HarnessCommandGateway getGateway() { + return harness.getGateway(); + } + + public HarnessRunResult run(Object input) { + return harness.run(input); + } + + public HarnessRunResult run(String sessionId, Object input) { + return harness.run(HarnessRunRequest.builder() + .sessionId(sessionId) + .input(input) + .build()); + } + + public HarnessRunResult run(HarnessRunRequest request) { + return harness.run(request); + } + + public HarnessRunResult runTask(String taskId, Object input) { + return harness.runTask(taskId, input); + } + + public HarnessRunResult resume(String executionId) { + return harness.resume(executionId); + } + + public HarnessRunResult resumeTask(String taskId) { + return harness.resumeTask(taskId); + } + + public HarnessRunResult deliver(String waitId, Object input) { + return harness.deliver(waitId, input); + } + + public List runReady(HarnessRunBudget budget) { + return harness.runReady(budget); + } + + @Override + public void close() { + harness.close(); + } + + public static final class Builder { + private CodingAgent codingAgent; + private HarnessStore store; + private HarnessPersistence persistence; + private HarnessContract contract; + private HarnessActor actor; + private String workerId; + private boolean autoResume = true; + private HarnessRunListener listener; + + public Builder codingAgent(CodingAgent value) { + this.codingAgent = value; + return this; + } + + public Builder store(HarnessStore value) { + this.store = value; + return this; + } + + public Builder persistence(HarnessPersistence value) { + this.persistence = value; + return this; + } + + public Builder contract(HarnessContract value) { + this.contract = value; + return this; + } + + public Builder actor(HarnessActor value) { + this.actor = value; + return this; + } + + public Builder workerId(String value) { + this.workerId = value; + return this; + } + + public Builder autoResume(boolean value) { + this.autoResume = value; + return this; + } + + public Builder listener(HarnessRunListener value) { + this.listener = value; + return this; + } + + public CodingAgentHarness build() { + if (codingAgent == null) { + throw new IllegalStateException("codingAgent is required"); + } + AgentHarness harness = AgentHarness.builder() + .executionAdapter(new CodingAgentHarnessExecutionAdapter(codingAgent)) + .store(store) + .persistence(persistence) + .contract(contract) + .actor(actor) + .workerId(workerId) + .autoResume(autoResume) + .listener(listener) + .build(); + return new CodingAgentHarness(harness); + } + } +} diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentHarnessExecutionAdapter.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentHarnessExecutionAdapter.java new file mode 100644 index 00000000..9ac60e0a --- /dev/null +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentHarnessExecutionAdapter.java @@ -0,0 +1,366 @@ +package io.github.lnyocly.ai4j.coding; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import io.github.lnyocly.ai4j.agent.AgentContext; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; +import io.github.lnyocly.ai4j.agent.AgentOptions; +import io.github.lnyocly.ai4j.agent.AgentRequest; +import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import io.github.lnyocly.ai4j.harness.HarnessAdapterDelivery; +import io.github.lnyocly.ai4j.harness.HarnessAdapterExecution; +import io.github.lnyocly.ai4j.harness.HarnessAdapterState; +import io.github.lnyocly.ai4j.harness.HarnessExecutionAdapter; +import io.github.lnyocly.ai4j.harness.HarnessExecutionAdapterSession; +import io.github.lnyocly.ai4j.harness.HarnessExecutionContext; +import io.github.lnyocly.ai4j.harness.HarnessPrompts; +import io.github.lnyocly.ai4j.harness.HarnessRunBudget; +import io.github.lnyocly.ai4j.harness.HarnessToolExecutor; +import io.github.lnyocly.ai4j.harness.HarnessToolInterceptor; +import io.github.lnyocly.ai4j.harness.HarnessToolRegistry; +import io.github.lnyocly.ai4j.harness.WaitRecord; +import io.github.lnyocly.ai4j.harness.WaitType; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Runs the existing {@link CodingAgent} through the generic Harness protocol. + * + *

The adapter deliberately opens a real {@link CodingSession} for every + * execution slice. This keeps the coding outer loop, compaction, workspace + * tools, sandbox binding, sub-agent routing and process registry in charge of + * coding behavior while the Harness owns durable execution lifecycle.

+ */ +public final class CodingAgentHarnessExecutionAdapter implements HarnessExecutionAdapter { + + public static final String ADAPTER_TYPE = "ai4j-coding"; + public static final String CODING_SESSION_STATE = "codingSessionState"; + + private final CodingAgent codingAgent; + + public CodingAgentHarnessExecutionAdapter(CodingAgent codingAgent) { + if (codingAgent == null) { + throw new IllegalArgumentException("codingAgent is required"); + } + this.codingAgent = codingAgent; + } + + public CodingAgent getCodingAgent() { + return codingAgent; + } + + @Override + public String getAdapterType() { + return ADAPTER_TYPE; + } + + @Override + public HarnessExecutionAdapterSession open(HarnessExecutionContext executionContext, + HarnessRunBudget budget, + HarnessAdapterState previousState) { + CodingSessionState state = decodeState(previousState); + String sessionId = firstNonBlank(executionContext.getSessionId(), + state == null ? null : state.getSessionId()); + if (state != null && state.getSessionId() != null + && sessionId != null && !sessionId.equals(state.getSessionId())) { + throw new IllegalArgumentException("coding checkpoint session does not match execution session"); + } + CodingSession session = codingAgent.newSession(sessionId, state); + applyHarnessOverlay(session, executionContext, budget); + return new Session(executionContext, session); + } + + @Override + public HarnessAdapterDelivery applyDelivery(HarnessAdapterState state, + WaitRecord wait, + Object input) { + CodingSessionState codingState = decodeState(state); + if (codingState == null || codingState.getMemorySnapshot() == null) { + return HarnessAdapterDelivery.builder() + .state(state) + .replacedPendingResult(false) + .build(); + } + String callId = callId(wait); + if (callId == null) { + return HarnessAdapterDelivery.builder() + .state(withState(state, codingState)) + .replacedPendingResult(false) + .build(); + } + String output = WaitType.APPROVAL.equals(wait == null ? null : wait.getType()) + ? "HARNESS_APPROVAL_GRANTED: retry the approved tool call. delivery=" + stringify(input) + : stringify(input); + MemorySnapshot memory = codingState.getMemorySnapshot(); + List items = memory.getItems(); + if (items == null) { + return HarnessAdapterDelivery.builder() + .state(withState(state, codingState)) + .replacedPendingResult(false) + .build(); + } + boolean replaced = false; + for (Object item : items) { + if (replaceFunctionCallOutput(item, callId, output) + || replaceCodeActMarker(item, callId, output)) { + replaced = true; + break; + } + } + if (replaced) { + memory.setItems(items); + codingState.setMemorySnapshot(memory); + } + return HarnessAdapterDelivery.builder() + .state(withState(state, codingState)) + .replacedPendingResult(replaced) + .build(); + } + + private void applyHarnessOverlay(CodingSession session, + HarnessExecutionContext executionContext, + HarnessRunBudget budget) { + if (session == null || session.getDelegate() == null + || session.getDelegate().getContext() == null) { + throw new IllegalStateException("coding session context is required"); + } + AgentContext context = session.getDelegate().getContext(); + AgentOptions options = context.getOptions() == null + ? AgentOptions.builder().build() : context.getOptions(); + AgentOptions.AgentOptionsBuilder optionsBuilder = options.toBuilder(); + if (budget != null) { + if (budget.getMaxSteps() > 0) { + optionsBuilder.maxSteps(budget.getMaxSteps()); + } + if (budget.getMaxWallTimeMillis() > 0L) { + optionsBuilder.wallClockTimeoutMillis(budget.getMaxWallTimeMillis()); + } + if (budget.getMaxTokenBudget() >= 0L) { + optionsBuilder.maxTokenBudget(budget.getMaxTokenBudget()); + } + } + + // Capture the coding runtime's resolved tools before replacing the + // context with the Harness-enforced overlay. + io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry businessRegistry = context.getToolRegistry(); + ToolExecutor businessExecutor = context.getToolExecutor(); + context.setToolRegistry(new HarnessToolRegistry(businessRegistry)); + context.setToolExecutor(new HarnessToolExecutor(executionContext, businessExecutor)); + context.setToolInterceptor(new HarnessToolInterceptor(context.getToolInterceptor())); + context.setOptions(optionsBuilder.build()); + context.setSessionId(session.getSessionId()); + context.setSystemPrompt(appendPrompt(context.getSystemPrompt(), HarnessPrompts.instructions())); + } + + private CodingSessionState decodeState(HarnessAdapterState state) { + if (state == null || state.getPayload() == null) { + return null; + } + Object raw = state.getPayload().get(CODING_SESSION_STATE); + if (raw == null) { + return null; + } + return JSON.parseObject(JSON.toJSONString(raw), CodingSessionState.class); + } + + private HarnessAdapterState withState(HarnessAdapterState source, + CodingSessionState codingState) { + Map payload = source == null || source.getPayload() == null + ? new LinkedHashMap() + : new LinkedHashMap(source.getPayload()); + payload.put(CODING_SESSION_STATE, codingState); + return HarnessAdapterState.builder() + .adapterType(ADAPTER_TYPE) + .sessionId(codingState == null + ? source == null ? null : source.getSessionId() : codingState.getSessionId()) + .payload(payload) + .build(); + } + + private String callId(WaitRecord wait) { + if (wait == null || wait.getPayload() == null) { + return null; + } + Object raw = wait.getPayload().get(AgentToolCall.METADATA_KEY_PARENT_CALL_ID); + if (raw == null || String.valueOf(raw).trim().isEmpty()) { + raw = wait.getPayload().get("callId"); + } + return raw == null || String.valueOf(raw).trim().isEmpty() ? null : String.valueOf(raw); + } + + private boolean replaceFunctionCallOutput(Object item, String callId, String output) { + if (!(item instanceof Map)) { + return false; + } + Map candidate = (Map) item; + if (!"function_call_output".equals(String.valueOf(candidate.get("type"))) + || !callId.equals(String.valueOf(candidate.get("call_id")))) { + return false; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) item; + mutable.put("output", output); + return true; + } + + private boolean replaceCodeActMarker(Object item, String callId, String output) { + if (!(item instanceof Map)) { + return false; + } + Map message = (Map) item; + if (!"message".equals(String.valueOf(message.get("type"))) + || !"system".equals(String.valueOf(message.get("role")))) { + return false; + } + Object rawContent = message.get("content"); + if (!(rawContent instanceof List)) { + return false; + } + for (Object block : (List) rawContent) { + if (!(block instanceof Map)) { + continue; + } + Map content = (Map) block; + Object rawText = content.get("text"); + if (rawText == null || !String.valueOf(rawText) + .startsWith(AgentToolCall.CODEACT_PENDING_RESULT_PREFIX)) { + continue; + } + String markerJson = String.valueOf(rawText) + .substring(AgentToolCall.CODEACT_PENDING_RESULT_PREFIX.length()).trim(); + try { + JSONObject marker = JSON.parseObject(markerJson); + if (marker == null || !callId.equals(String.valueOf(marker.get("callId")))) { + continue; + } + } catch (RuntimeException ignored) { + continue; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) block; + mutable.put("text", "CODE_RESULT: " + stringify(output)); + return true; + } + return false; + } + + private String appendPrompt(String base, String addition) { + if (base == null || base.trim().isEmpty()) { + return addition; + } + if (addition == null || addition.trim().isEmpty()) { + return base; + } + return base + "\n" + addition; + } + + private String stringify(Object value) { + return value instanceof String ? (String) value : JSON.toJSONString(value); + } + + private String firstNonBlank(String first, String second) { + return trimToNull(first) == null ? trimToNull(second) : trimToNull(first); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + private final class Session implements HarnessExecutionAdapterSession { + private final HarnessExecutionContext executionContext; + private final CodingSession codingSession; + + private Session(HarnessExecutionContext executionContext, CodingSession codingSession) { + this.executionContext = executionContext; + this.codingSession = codingSession; + } + + @Override + public HarnessAdapterExecution run(AgentRequest request) throws Exception { + String input = request == null ? null : inputText(request.getInput()); + Map metadata = request == null || request.getMetadata() == null + ? new LinkedHashMap() + : new LinkedHashMap(request.getMetadata()); + CodingAgentResult result = codingSession.run(CodingAgentRequest.builder() + .input(input) + .metadata(metadata) + .build()); + AgentExecutionStatus status = result == null || result.getExecutionStatus() == null + ? AgentExecutionStatus.FAILED : result.getExecutionStatus(); + String error = AgentExecutionStatus.FAILED.equals(status) + ? "Coding Agent slice failed" : null; + return HarnessAdapterExecution.builder() + .status(status) + .outputText(result == null ? null : result.getOutputText()) + .error(error) + .waitId(result == null ? null : result.getWaitId()) + .operationId(result == null ? null : result.getOperationId()) + .checkpointSummary(checkpointSummary(status, result)) + .checkpointState(checkpointState(status, result)) + .result(result) + .build(); + } + + @Override + public HarnessAdapterState snapshot() { + return withState(HarnessAdapterState.builder() + .adapterType(ADAPTER_TYPE) + .sessionId(executionContext.getSessionId()) + .payload(new LinkedHashMap()) + .build(), codingSession.exportState()); + } + + @Override + public void close() { + codingSession.close(); + } + } + + private String inputText(Object input) { + if (input == null || input instanceof String) { + return (String) input; + } + return JSON.toJSONString(input); + } + + private String checkpointSummary(AgentExecutionStatus status, CodingAgentResult result) { + if (AgentExecutionStatus.WAITING.equals(status)) { + return "Coding slice is waiting for a durable wakeup"; + } + if (AgentExecutionStatus.CONTINUATION_REQUIRED.equals(status)) { + return "Coding slice reached its Agent step boundary"; + } + if (AgentExecutionStatus.FAILED.equals(status)) { + return "Coding slice failed"; + } + String output = result == null ? null : result.getOutputText(); + return output == null || output.trim().isEmpty() + ? "Coding slice completed" : "Coding slice completed: " + output; + } + + private Map checkpointState(AgentExecutionStatus status, + CodingAgentResult result) { + Map state = new LinkedHashMap(); + state.put("status", status == null ? null : status.name()); + if (result != null) { + state.put("sessionId", result.getSessionId()); + state.put("runId", result.getRunId()); + state.put("turnId", result.getTurnId()); + state.put("steps", result.getSteps()); + state.put("turns", result.getTurns()); + state.put("stopReason", result.getStopReason() == null + ? null : result.getStopReason().name()); + } + return state; + } +} diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentResult.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentResult.java index bc986832..9255024a 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentResult.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/CodingAgentResult.java @@ -1,6 +1,7 @@ package io.github.lnyocly.ai4j.coding; import io.github.lnyocly.ai4j.agent.AgentResult; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; import io.github.lnyocly.ai4j.coding.loop.CodingStopReason; @@ -72,6 +73,15 @@ public class CodingAgentResult { private String currency; + /** Structured status propagated from the underlying Agent slice. */ + private AgentExecutionStatus executionStatus; + + /** Stable operation identity when a coding tool is waiting asynchronously. */ + private String operationId; + + /** Stable wait identity used by the Harness host to resume this slice. */ + private String waitId; + public CodingAgentResult(String runId, String sessionId, String turnId, @@ -132,6 +142,9 @@ public static CodingAgentResult from(String sessionId, AgentResult result) { .outputCost(result.getOutputCost()) .totalCost(result.getTotalCost()) .currency(result.getCurrency()) + .executionStatus(result.getExecutionStatus()) + .operationId(result.getOperationId()) + .waitId(result.getWaitId()) .build(); } } diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopController.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopController.java index da9f5044..2f230856 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopController.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingAgentLoopController.java @@ -1,5 +1,6 @@ package io.github.lnyocly.ai4j.coding.loop; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; import io.github.lnyocly.ai4j.agent.event.AgentEvent; import io.github.lnyocly.ai4j.agent.event.AgentListener; import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; @@ -171,6 +172,30 @@ private CodingLoopDecision decide(CodingLoopPolicy policy, boolean explicitQuestion = policy.isStopOnExplicitQuestion() && looksLikeQuestion(outputText); boolean candidateContinue = shouldContinue(policy, result, compactApplied, outputText); + // Structured runtime status is authoritative. Text heuristics must not + // auto-follow a durable wait or a lower-level execution boundary. + if (result != null && AgentExecutionStatus.WAITING.equals(result.getExecutionStatus())) { + return stopDecision(turnNumber, CodingStopReason.NEEDS_USER_INPUT, + "Stopped because a tool is waiting for durable external completion.") + .toBuilder() + .compactApplied(compactApplied) + .build(); + } + if (result != null && AgentExecutionStatus.FAILED.equals(result.getExecutionStatus())) { + return stopDecision(turnNumber, CodingStopReason.ERROR, + "Stopped because the Agent slice failed.") + .toBuilder() + .compactApplied(compactApplied) + .build(); + } + if (result != null && AgentExecutionStatus.CONTINUATION_REQUIRED.equals(result.getExecutionStatus())) { + return stopDecision(turnNumber, CodingStopReason.CONTINUATION_REQUIRED, + "Stopped at the Agent slice boundary; the Harness can resume the coding session.") + .toBuilder() + .compactApplied(compactApplied) + .build(); + } + if (approvalBlocked && policy.isStopOnApprovalBlock()) { return stopDecision(turnNumber, CodingStopReason.BLOCKED_BY_APPROVAL, "Stopped because tool approval was rejected.") .toBuilder() @@ -389,9 +414,17 @@ private CodingAgentResult aggregate(CodingSession session, .autoContinued(autoFollowUps > 0) .autoFollowUpCount(autoFollowUps) .lastCompactApplied(decision != null && decision.isCompactApplied()) + .executionStatus(effectiveStatus(lastResult)) + .operationId(lastResult == null ? null : lastResult.getOperationId()) + .waitId(lastResult == null ? null : lastResult.getWaitId()) .build(); } + private AgentExecutionStatus effectiveStatus(CodingAgentResult result) { + return result == null || result.getExecutionStatus() == null + ? AgentExecutionStatus.COMPLETED : result.getExecutionStatus(); + } + private boolean hasApprovalBlockedResult(CodingAgentResult result) { if (result == null || result.getToolResults() == null) { return false; diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingStopReason.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingStopReason.java index 337fc6e3..b0688f63 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingStopReason.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/loop/CodingStopReason.java @@ -7,6 +7,7 @@ public enum CodingStopReason { BLOCKED_BY_TOOL_ERROR, MAX_AUTO_FOLLOWUPS_REACHED, MAX_TOTAL_TURNS_REACHED, + CONTINUATION_REQUIRED, INTERRUPTED, ERROR } diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/SessionProcessRegistry.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/SessionProcessRegistry.java index 1ca713be..db493f9b 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/SessionProcessRegistry.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/process/SessionProcessRegistry.java @@ -329,9 +329,11 @@ private void stop(long graceMs) { } return; } + killDescendants(false); process.destroy(); try { if (!process.waitFor(graceMs, TimeUnit.MILLISECONDS)) { + killDescendants(true); process.destroyForcibly(); process.waitFor(graceMs, TimeUnit.MILLISECONDS); } @@ -354,6 +356,37 @@ private static Long safePid(Process process) { return null; } } + + /** + * Kills child processes of the wrapped process before killing it. + * Commands run through a shell wrapper ({@code cmd.exe /c} on Windows, + * {@code sh -lc} elsewhere), so the real workload is usually a + * grandchild; killing only the direct child orphans it and it keeps + * running (e.g. a listening server port). Uses + * {@code Process.descendants()} reflectively so the module still + * compiles and runs on Java 8, where the fallback is the historical + * direct-child-only behaviour. + */ + private void killDescendants(boolean forcibly) { + try { + // Route both calls through public interface classes: reflecting + // on implementation classes (ReferencePipeline, ProcessHandleImpl) + // is rejected by module access on JDK 9+. + Class processHandle = Class.forName("java.lang.ProcessHandle"); + String killMethod = forcibly ? "destroyForcibly" : "destroy"; + Object stream = Process.class.getMethod("descendants").invoke(process); + java.util.function.Consumer killer = handle -> { + try { + processHandle.getMethod(killMethod).invoke(handle); + } catch (Throwable ignored) { + } + }; + ((java.util.stream.Stream) stream).forEach(killer); + } catch (Throwable ignored) { + // Java 8 runtime: ProcessHandle does not exist; caller still + // kills the direct child as before. + } + } } private static class StreamCollector implements Runnable { diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/prompt/CodingContextPromptAssembler.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/prompt/CodingContextPromptAssembler.java index 0dfe1195..e2da666c 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/prompt/CodingContextPromptAssembler.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/prompt/CodingContextPromptAssembler.java @@ -36,13 +36,8 @@ private static String buildWorkspacePrompt(WorkspaceContext workspaceContext) { if (!isBlank(workspaceContext.getDescription())) { builder.append("Workspace description: ").append(workspaceContext.getDescription()).append("\n"); } - builder.append("Available built-in tools: bash, read_file, write_file, apply_patch, glob, grep, edit, update_agents_md.\n"); - builder.append("Use bash for search, git, build, test, and process management. Use read_file before making changes. Use write_file for full-file create/overwrite/append operations, especially for new files. Use apply_patch for structured diffs. Use glob to find files by name pattern (**/*.java). Use grep to search file contents by regex. Use edit for precise string replacements in existing files. Use update_agents_md to persist project conventions and decisions.\n"); - builder.append("Tool-call rules: only call a tool when you have a complete payload. ") - .append("For bash, always send a JSON object like {\"action\":\"exec\",\"command\":\"...\"} and never omit command for exec/start. ") - .append("Use bash action=exec only for non-interactive commands that will exit by themselves. If a command may wait for stdin, open a REPL, start a server, tail logs, or keep running, use bash action=start and then bash action=logs/status/write/stop. ") - .append("For read_file, include path. For write_file, include path and content, plus optional mode=create|overwrite|append. Relative paths resolve from the workspace root and absolute paths are allowed. For apply_patch, include patch.\n"); - builder.append("apply_patch must use the exact grammar: *** Begin Patch, then *** Add File:/*** Update File:/*** Delete File:, and end with *** End Patch.\n"); + builder.append("Tool selection: read_file before changing a file; write_file for whole files; edit for precise replacements; apply_patch for multi-file structured diffs; bash for self-terminating commands (search, git, build, test); bash_process to start and manage interactive or long-running processes; update_agents_md to persist project conventions and decisions.\n"); + builder.append("apply_patch grammar: *** Begin Patch, then *** Add File:/*** Update File:/*** Delete File:, end with *** End Patch.\n"); builder.append(ShellCommandSupport.buildShellUsageGuidance()).append("\n"); if (!workspaceContext.isAllowOutsideWorkspace()) { builder.append("Do not rely on files outside the workspace root unless the user explicitly allows it.\n"); diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolNames.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolNames.java index 77f43a5a..fe5c4fe7 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolNames.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/CodingToolNames.java @@ -7,6 +7,7 @@ public final class CodingToolNames { public static final String BASH = BuiltInTools.BASH; + public static final String BASH_PROCESS = BuiltInTools.BASH_PROCESS; public static final String READ_FILE = BuiltInTools.READ_FILE; public static final String WRITE_FILE = BuiltInTools.WRITE_FILE; public static final String APPLY_PATCH = BuiltInTools.APPLY_PATCH; diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/RoutingToolExecutor.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/RoutingToolExecutor.java index 82eae4df..eb89d4a4 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/RoutingToolExecutor.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/RoutingToolExecutor.java @@ -1,6 +1,9 @@ package io.github.lnyocly.ai4j.coding.tool; import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; import java.util.ArrayList; @@ -9,7 +12,7 @@ import java.util.List; import java.util.Set; -public class RoutingToolExecutor implements ToolExecutor { +public class RoutingToolExecutor implements AsyncToolExecutor { private final List routes; private final ToolExecutor fallbackExecutor; @@ -37,6 +40,25 @@ public String execute(AgentToolCall call) throws Exception { throw new IllegalArgumentException("No tool executor found for tool: " + toolName); } + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + String toolName = call == null ? null : call.getName(); + ToolExecutor executor = resolve(toolName); + return AsyncToolExecutors.start(executor, call); + } + + private ToolExecutor resolve(String toolName) { + for (Route route : routes) { + if (route.supports(toolName)) { + return route.getExecutor(); + } + } + if (fallbackExecutor != null) { + return fallbackExecutor; + } + throw new IllegalArgumentException("No tool executor found for tool: " + toolName); + } + public static Route route(Set toolNames, ToolExecutor executor) { return new Route(toolNames, executor); } diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ToolExecutorDecorator.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ToolExecutorDecorator.java index f2a69021..ac70b682 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ToolExecutorDecorator.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/ToolExecutorDecorator.java @@ -1,8 +1,19 @@ package io.github.lnyocly.ai4j.coding.tool; import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; public interface ToolExecutorDecorator { ToolExecutor decorate(String toolName, ToolExecutor delegate); + + /** + * Optional asynchronous decoration hook. Existing decorators remain + * source-compatible and fall back to their synchronous implementation; + * decorators that wrap pending tools should override this method so the + * completion stage is not collapsed into a blocking call. + */ + default ToolExecutor decorateAsync(String toolName, AsyncToolExecutor delegate) { + return decorate(toolName, delegate); + } } diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/WorkspacePathGuard.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/WorkspacePathGuard.java index dc33f558..fb3f82f3 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/WorkspacePathGuard.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/tool/WorkspacePathGuard.java @@ -162,11 +162,16 @@ static void rejectBlacklisted(Path canonical, Path workspaceRoot, String origina /** * Reject writes inside directories listed in {@link WorkspaceContext#getExcludedPaths()}. + * The message doubles as model guidance: excluded entries are the caller's + * write policy (defaults like {@code .git}, or declared inputs/tests), so the + * model is told to redirect instead of retrying. */ static void rejectExcluded(Path canonical, WorkspaceContext workspaceContext, String originalPath) { if (workspaceContext.isExcluded(canonical)) { throw new IllegalArgumentException( - "Write to excluded path is blocked: " + originalPath); + "Write to excluded path is blocked by workspace write policy: " + originalPath + + ". This location is protected (task input, tests, or system area) —" + + " do not modify it; produce outputs elsewhere per the task instructions."); } } diff --git a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceContext.java b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceContext.java index ae214bef..7757e397 100644 --- a/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceContext.java +++ b/ai4j-coding/src/main/java/io/github/lnyocly/ai4j/coding/workspace/WorkspaceContext.java @@ -98,6 +98,16 @@ public List getAllowedReadRootPaths() { return paths; } + /** + * Write-policy check used by {@code WorkspacePathGuard}. A plain entry + * (no {@code *} / {@code ?}) keeps the historical behavior: it matches any + * single path segment with that name anywhere in the tree (e.g. {@code in} + * protects every {@code in/} directory). An entry containing {@code *} or + * {@code ?} is treated as a glob over the root-relative path with + * {@code /} separators, where {@code **} spans directories + * (e.g. a pattern like doublestar-slash-test_underscore-star-dot-py + * protects test files at any depth). + */ public boolean isExcluded(Path absolutePath) { Path root = getRoot(); if (absolutePath == null || !absolutePath.startsWith(root)) { @@ -109,10 +119,91 @@ public boolean isExcluded(Path absolutePath) { return true; } } + String relativePath = relative.toString().replace('\\', '/'); + for (String pattern : excludedPaths) { + if (isGlobPattern(pattern) && globMatches(pattern, relativePath)) { + return true; + } + } return false; } - private static List defaultExcludedPaths() { + static boolean isGlobPattern(String pattern) { + return pattern != null && (pattern.indexOf('*') >= 0 || pattern.indexOf('?') >= 0); + } + + /** + * Minimal glob matcher for root-relative {@code /}-separated paths: + * {@code **} spans directory boundaries, {@code *} matches within one + * segment, {@code ?} matches one non-separator character. + */ + static boolean globMatches(String pattern, String relativePath) { + return globMatches(pattern, 0, relativePath, 0); + } + + private static boolean globMatches(String pattern, int p, String path, int i) { + while (p < pattern.length()) { + char pc = pattern.charAt(p); + if (pc == '*') { + boolean doubleStar = p + 1 < pattern.length() && pattern.charAt(p + 1) == '*'; + if (doubleStar) { + p += 2; + // "**/" collapses to zero or more segments: try the rest + // directly at i (zero dirs), then after every separator. + if (p < pattern.length() && pattern.charAt(p) == '/') { + p++; + if (globMatches(pattern, p, path, i)) { + return true; + } + for (int skip = i; skip < path.length(); skip++) { + if (path.charAt(skip) == '/' && globMatches(pattern, p, path, skip + 1)) { + return true; + } + } + return false; + } + if (globMatches(pattern, p, path, path.length())) { + return true; + } + for (int skip = i; skip < path.length(); skip++) { + if (globMatches(pattern, p, path, skip)) { + return true; + } + } + return false; + } + int nextSlash = path.indexOf('/', i); + int segmentEnd = nextSlash < 0 ? path.length() : nextSlash; + for (int end = path.length(); end >= i; end--) { + if (end <= segmentEnd && globMatches(pattern, p + 1, path, end)) { + return true; + } + } + return false; + } + if (pc == '?') { + if (i >= path.length() || path.charAt(i) == '/') { + return false; + } + p++; + i++; + continue; + } + if (i >= path.length() || path.charAt(i) != pc) { + return false; + } + p++; + i++; + } + return i == path.length(); + } + + /** + * The default write-policy entries (VCS/build/IDE areas). Exposed so + * callers extending {@code excludedPaths} (e.g. protected inputs) can + * append without losing these. + */ + public static List defaultExcludedPaths() { return new ArrayList<>(Arrays.asList(".git", "target", "node_modules", ".idea")); } diff --git a/ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingAgentHarnessTest.java b/ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingAgentHarnessTest.java new file mode 100644 index 00000000..627003e1 --- /dev/null +++ b/ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/CodingAgentHarnessTest.java @@ -0,0 +1,245 @@ +package io.github.lnyocly.ai4j.coding; + +import com.alibaba.fastjson2.JSON; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; +import io.github.lnyocly.ai4j.agent.model.AgentModelClient; +import io.github.lnyocly.ai4j.agent.model.AgentModelResult; +import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; +import io.github.lnyocly.ai4j.agent.model.AgentPrompt; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; +import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import io.github.lnyocly.ai4j.harness.HarnessAdapterState; +import io.github.lnyocly.ai4j.harness.HarnessPersistence; +import io.github.lnyocly.ai4j.harness.HarnessRunBudget; +import io.github.lnyocly.ai4j.harness.HarnessRunRequest; +import io.github.lnyocly.ai4j.harness.HarnessRunResult; +import io.github.lnyocly.ai4j.harness.HarnessRunStatus; +import io.github.lnyocly.ai4j.harness.WaitStatus; +import io.github.lnyocly.ai4j.harness.WaitType; +import io.github.lnyocly.ai4j.platform.openai.tool.Tool; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class CodingAgentHarnessTest { + + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void persistsCodingStateAndRestoresItForNewExecutionAndHarnessReopen() throws Exception { + Path harnessDirectory = temporaryFolder.newFolder("coding-harness").toPath(); + Path workspace = temporaryFolder.newFolder("coding-workspace").toPath(); + + CodingAgent firstAgent = codingAgent(new QueueModelClient( + toolCallResult("echo-call", "echo"), + textResult("completed after the new message")), new ToolExecutor() { + @Override + public String execute(AgentToolCall call) { + return "echo-result"; + } + }, workspace); + CodingAgentHarness firstHarness = CodingAgentHarness.builder() + .codingAgent(firstAgent) + .persistence(HarnessPersistence.file(harnessDirectory)) + .autoResume(false) + .build(); + + HarnessRunResult first = firstHarness.run(HarnessRunRequest.builder() + .sessionId("coding-project") + .input("implement the payment module") + .budget(HarnessRunBudget.builder().maxSteps(1).build()) + .build()); + + assertEquals(HarnessRunStatus.CONTINUATION_REQUIRED, first.getStatus()); + assertEquals(AgentExecutionStatus.CONTINUATION_REQUIRED, + ((CodingAgentResult) first.getAdapterResult()).getExecutionStatus()); + assertNotNull(first.getExecution().getCheckpointId()); + HarnessAdapterState checkpointState = adapterState(firstHarness, first.getExecution().getCheckpointId()); + assertEquals(CodingAgentHarnessExecutionAdapter.ADAPTER_TYPE, checkpointState.getAdapterType()); + assertNotNull(checkpointState.getPayload().get( + CodingAgentHarnessExecutionAdapter.CODING_SESSION_STATE)); + + HarnessRunResult nextMessage = firstHarness.run(HarnessRunRequest.builder() + .sessionId("coding-project") + .input("continue the payment module") + .build()); + assertEquals(HarnessRunStatus.COMPLETED, nextMessage.getStatus()); + assertEquals("completed after the new message", nextMessage.getOutputText()); + assertEquals("coding-project", nextMessage.getExecution().getSessionId()); + assertFalse(first.getExecution().getExecutionId() + .equals(nextMessage.getExecution().getExecutionId())); + firstHarness.close(); + + CodingAgent reopenedAgent = codingAgent(new QueueModelClient( + textResult("completed after the harness reopened")), new ToolExecutor() { + @Override + public String execute(AgentToolCall call) { + return "echo-result"; + } + }, workspace); + CodingAgentHarness reopened = CodingAgentHarness.builder() + .codingAgent(reopenedAgent) + .persistence(HarnessPersistence.file(harnessDirectory)) + .autoResume(false) + .build(); + try { + HarnessRunResult resumed = reopened.resume(first.getExecution().getExecutionId()); + assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + assertEquals("completed after the harness reopened", resumed.getOutputText()); + assertEquals("coding-project", resumed.getExecution().getSessionId()); + } finally { + reopened.close(); + } + } + + @Test + public void asyncFunctionCallWaitsDurablyAndResumesCodingSession() throws Exception { + Path harnessDirectory = temporaryFolder.newFolder("coding-async-harness").toPath(); + Path workspace = temporaryFolder.newFolder("coding-async-workspace").toPath(); + final CompletableFuture completion = new CompletableFuture(); + CodingAgent agent = codingAgent(new QueueModelClient( + toolCallResult("slow-call", "slow_operation"), + textResult("async coding operation completed")), new AsyncToolExecutor() { + @Override + public AgentToolExecution start(AgentToolCall call) { + return AgentToolExecution.pending("operation-42", null, + "slow operation is pending", null, completion); + } + }, workspace); + CodingAgentHarness harness = CodingAgentHarness.builder() + .codingAgent(agent) + .persistence(HarnessPersistence.file(harnessDirectory)) + .autoResume(false) + .build(); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("coding-async-session") + .input("run the remote build") + .build()); + + assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + CodingAgentResult waitingResult = (CodingAgentResult) waiting.getAdapterResult(); + assertEquals(AgentExecutionStatus.WAITING, waitingResult.getExecutionStatus()); + assertEquals("operation-42", waitingResult.getOperationId()); + assertNotNull(waiting.getWaitId()); + assertEquals(WaitType.ASYNC_OPERATION, + harness.getGateway().getWait(waiting.getWaitId()).getType()); + + completion.complete(AgentToolResult.builder() + .output("remote build finished") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + awaitWait(harness, waiting.getWaitId()); + assertEquals(WaitStatus.DELIVERED, + harness.getGateway().getWait(waiting.getWaitId()).getStatus()); + + HarnessRunResult resumed = harness.resume(waiting.getExecution().getExecutionId()); + assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + assertEquals("async coding operation completed", resumed.getOutputText()); + harness.close(); + } + + private CodingAgent codingAgent(AgentModelClient model, + ToolExecutor executor, + Path workspace) { + return CodingAgents.builder() + .modelClient(model) + .model("test-coding-model") + .workspaceContext(io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext.builder() + .rootPath(workspace.toString()) + .build()) + .codingOptions(CodingAgentOptions.builder() + .includeBuiltInTools(false) + .build()) + .toolRegistry(singleToolRegistry(executor instanceof AsyncToolExecutor + ? "slow_operation" : "echo")) + .toolExecutor(executor) + .build(); + } + + private AgentToolRegistry singleToolRegistry(String name) { + Tool.Function function = new Tool.Function(); + function.setName(name); + function.setDescription("Test coding tool"); + return new StaticToolRegistry(Collections.singletonList(new Tool("function", function))); + } + + private AgentModelResult toolCallResult(String callId, String name) { + return AgentModelResult.builder() + .toolCalls(Collections.singletonList(AgentToolCall.builder() + .callId(callId) + .name(name) + .arguments("{}") + .type("function") + .build())) + .memoryItems(Collections.emptyList()) + .build(); + } + + private AgentModelResult textResult(String text) { + return AgentModelResult.builder() + .outputText(text) + .toolCalls(Collections.emptyList()) + .memoryItems(Collections.emptyList()) + .build(); + } + + private HarnessAdapterState adapterState(CodingAgentHarness harness, String checkpointId) { + Object raw = harness.getGateway().getCheckpoint(checkpointId).getState() + .get("harnessAdapterState"); + return JSON.parseObject(JSON.toJSONString(raw), HarnessAdapterState.class); + } + + private void awaitWait(CodingAgentHarness harness, String waitId) throws Exception { + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5L); + while (System.currentTimeMillis() < deadline) { + if (WaitStatus.DELIVERED.equals(harness.getGateway().getWait(waitId).getStatus())) { + return; + } + Thread.sleep(10L); + } + } + + private static final class QueueModelClient implements AgentModelClient { + private final Deque results; + + private QueueModelClient(AgentModelResult... results) { + this.results = new ArrayDeque(Arrays.asList(results)); + } + + @Override + public AgentModelResult create(AgentPrompt prompt) { + return results.isEmpty() ? AgentModelResult.builder() + .outputText("unexpected model call") + .toolCalls(new ArrayList()) + .build() : results.poll(); + } + + @Override + public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { + return create(prompt); + } + } +} diff --git a/ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/WorkspacePathGuardTest.java b/ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/WorkspacePathGuardTest.java index fa9e9488..4c91cde8 100644 --- a/ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/WorkspacePathGuardTest.java +++ b/ai4j-coding/src/test/java/io/github/lnyocly/ai4j/coding/WorkspacePathGuardTest.java @@ -174,6 +174,64 @@ public void shouldRespectAllowOutsideWorkspaceFlag() throws Exception { assertEquals(outside.toAbsolutePath().normalize(), resolved); } + @Test + public void shouldExcludePlainSegmentAnywhereInTree() throws Exception { + Path root = newWorkspace("plain-segment"); + WorkspaceContext context = WorkspaceContext.builder() + .rootPath(root.toString()) + .excludedPaths(java.util.Arrays.asList("in")) + .build(); + assertTrue(context.isExcluded(root.resolve("in").resolve("data.json"))); + assertTrue(context.isExcluded(root.resolve("nested").resolve("in").resolve("x.txt"))); + assertTrue(context.isExcluded(root.resolve("in"))); + assertTrue("plain segment must not leak into non-matching paths", + !context.isExcluded(root.resolve("out").resolve("result.json"))); + } + + @Test + public void shouldExcludeGlobPatternAtAnyDepth() throws Exception { + Path root = newWorkspace("glob-tests"); + WorkspaceContext context = WorkspaceContext.builder() + .rootPath(root.toString()) + .excludedPaths(java.util.Arrays.asList("**/test_*.py")) + .build(); + assertTrue(context.isExcluded(root.resolve("in/app/test_config.py"))); + assertTrue(context.isExcluded(root.resolve("tests/test_user.py"))); + assertTrue(context.isExcluded(root.resolve("test_config.py"))); + assertTrue("source files must stay writable", + !context.isExcluded(root.resolve("in/app/config_manager.py"))); + } + + @Test + public void shouldExcludeDirectoryContentsGlob() throws Exception { + Path root = newWorkspace("glob-dir"); + WorkspaceContext context = WorkspaceContext.builder() + .rootPath(root.toString()) + .excludedPaths(java.util.Arrays.asList("in/**")) + .build(); + assertTrue(context.isExcluded(root.resolve("in").resolve("data.json"))); + assertTrue(context.isExcluded(root.resolve("in").resolve("nested").resolve("x.txt"))); + assertTrue("the directory entry itself stays writable-irrelevant but unmatched", + !context.isExcluded(root.resolve("in"))); + assertTrue(!context.isExcluded(root.resolve("out").resolve("r.json"))); + } + + @Test + public void shouldRejectWriteIntoGlobProtectedInput() throws Exception { + Path root = newWorkspace("glob-reject"); + WorkspaceContext context = WorkspaceContext.builder() + .rootPath(root.toString()) + .excludedPaths(java.util.Arrays.asList("**/test_*.py")) + .build(); + try { + WorkspacePathGuard.resolveForWrite(context, "in/app/test_config.py"); + fail("Expected IllegalArgumentException for glob-protected path"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("workspace write policy")); + assertTrue(expected.getMessage().contains("do not modify")); + } + } + private Path newWorkspace(String name) throws Exception { return temporaryFolder.newFolder("workspace-" + name).toPath(); } diff --git a/ai4j-harness/pom.xml b/ai4j-harness/pom.xml new file mode 100644 index 00000000..a980acaa --- /dev/null +++ b/ai4j-harness/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + io.github.lnyo-cly + ai4j-sdk + 2.4.3-SNAPSHOT + + + ai4j-harness + jar + + ai4j-harness + Durable, governed long-running Agent Harness runtime for ai4j. + + + + io.github.lnyo-cly + ai4j-agent + ${project.version} + + + com.alibaba.fastjson2 + fastjson2 + 2.0.43 + + + org.projectlombok + lombok + 1.18.30 + provided + true + + + org.slf4j + slf4j-api + 1.7.30 + + + junit + junit + 4.13.2 + test + + + com.h2database + h2 + 2.2.224 + test + + + diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/AgentHarness.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/AgentHarness.java new file mode 100644 index 00000000..d00a1ccf --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/AgentHarness.java @@ -0,0 +1,1154 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import io.github.lnyocly.ai4j.agent.Agent; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; +import io.github.lnyocly.ai4j.agent.AgentRequest; +import io.github.lnyocly.ai4j.agent.AgentResult; +import io.github.lnyocly.ai4j.agent.control.AgentHostInputException; +import io.github.lnyocly.ai4j.agent.permission.AgentApprovalRequiredException; +import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; +import io.github.lnyocly.ai4j.agent.session.AgentSessionSnapshot; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; + +import javax.sql.DataSource; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Durable outer loop for an existing ai4j {@link Agent}. + * + *

Each public run executes one bounded slice. The Agent remains responsible + * for model calls, context projection, compaction, MCP, Function Call, + * permissions, sandboxing, hooks and all other existing capabilities. This + * class owns the long-running concerns around it: a durable Execution, lease, + * checkpoint, wait/wakeup, runtime-created Tasks and recovery.

+ */ +public final class AgentHarness implements AutoCloseable { + + private static final long DEFAULT_LEASE_MILLIS = 60_000L; + private static final String DEFAULT_WORKER_PREFIX = "ai4j-harness-worker-"; + private static final int EXECUTION_LOCK_STRIPES = 64; + + private final Agent agent; + private final HarnessExecutionAdapter executionAdapter; + private final HarnessStore store; + private final HarnessPersistence persistence; + private final HarnessCommandGateway gateway; + private final HarnessContract contract; + private final HarnessActor actor; + private final String workerId; + private final boolean autoResume; + private final HarnessRunListener listener; + private final ScheduledExecutorService heartbeatExecutor; + private final ExecutorService continuationExecutor; + private final ReentrantLock[] executionLocks; + + private AgentHarness(Builder builder) { + if (builder.agent == null && builder.executionAdapter == null) { + throw new IllegalArgumentException("agent or executionAdapter is required"); + } + if (builder.agent != null && builder.executionAdapter != null) { + throw new IllegalArgumentException("choose agent or executionAdapter, not both"); + } + if (builder.persistence != null && builder.store != null) { + throw new IllegalArgumentException("choose persistence or store, not both"); + } + this.agent = builder.agent; + this.executionAdapter = builder.executionAdapter == null + ? new AgentHarnessExecutionAdapter(builder.agent) : builder.executionAdapter; + this.persistence = builder.persistence; + this.store = builder.store == null + ? (builder.persistence == null ? null : builder.persistence.getStore()) + : builder.store; + if (this.store == null) { + throw new IllegalArgumentException("durable Harness persistence is required"); + } + this.contract = builder.contract == null ? HarnessContract.builder().build() : builder.contract; + this.actor = builder.actor == null ? HarnessActor.agent("ai4j-agent") : builder.actor; + this.workerId = builder.workerId == null || builder.workerId.trim().isEmpty() + ? DEFAULT_WORKER_PREFIX + UUID.randomUUID().toString().replace("-", "") + : builder.workerId.trim(); + this.autoResume = builder.autoResume; + this.listener = builder.listener; + this.gateway = new HarnessCommandGateway(store, contract, actor); + this.heartbeatExecutor = Executors.newScheduledThreadPool(1); + this.continuationExecutor = Executors.newCachedThreadPool(); + this.executionLocks = new ReentrantLock[EXECUTION_LOCK_STRIPES]; + for (int i = 0; i < executionLocks.length; i++) { + executionLocks[i] = new ReentrantLock(); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static AgentHarness file(Path directory, Agent agent) { + return builder().agent(agent).persistence(HarnessPersistence.file(directory)).build(); + } + + public static AgentHarness file(Path directory, + Agent agent, + HarnessContract contract) { + return builder().agent(agent).contract(contract) + .persistence(HarnessPersistence.file(directory)).build(); + } + + public static AgentHarness jdbc(DataSource dataSource, + String harnessId, + Agent agent) { + return builder().agent(agent) + .persistence(HarnessPersistence.jdbc(dataSource, harnessId)).build(); + } + + public Agent getAgent() { + return agent; + } + + public HarnessExecutionAdapter getExecutionAdapter() { + return executionAdapter; + } + + public HarnessCommandGateway getGateway() { + return gateway; + } + + public HarnessContract getContract() { + return contract; + } + + public HarnessRunResult run(Object input) { + return run(HarnessRunRequest.input(input)); + } + + /** Executes one durable, bounded slice. */ + public HarnessRunResult run(HarnessRunRequest request) { + HarnessRunRequest effectiveRequest = request == null + ? HarnessRunRequest.input(null) : request; + AgentRequest agentRequest = effectiveRequest.resolveAgentRequest(); + ExecutionRecord execution = resolveExecution(effectiveRequest, agentRequest); + return executeExecution(execution, agentRequest, effectiveRequest.getBudget()); + } + + /** Convenience entry point for an application that already has a Task id. */ + public HarnessRunResult runTask(String taskId, Object input) { + return run(HarnessRunRequest.builder().taskId(taskId).input(input).build()); + } + + /** Resumes a READY execution; WAITING executions must first receive a wakeup. */ + public HarnessRunResult resume(String executionId) { + return run(HarnessRunRequest.builder().executionId(executionId).build()); + } + + /** Finds the latest non-terminal execution for a Task and resumes it. */ + public HarnessRunResult resumeTask(String taskId) { + String taskKey = required(taskId, "task id"); + ExecutionRecord candidate = null; + for (ExecutionRecord execution : gateway.listExecutions()) { + if (execution == null || !taskKey.equals(execution.getTaskId())) { + continue; + } + if (execution.getStatus() == ExecutionStatus.READY + || execution.getStatus() == ExecutionStatus.RUNNING + || execution.getStatus() == ExecutionStatus.WAITING) { + if (candidate == null || execution.getUpdatedAtEpochMs() > candidate.getUpdatedAtEpochMs()) { + candidate = execution; + } + } + } + if (candidate == null) { + return runTask(taskKey, null); + } + return resume(candidate.getExecutionId()); + } + + /** + * Delivers a host/user/external answer. The answer and resumed Session are + * persisted atomically before the Execution becomes READY, then a new + * bounded slice is run synchronously. + */ + public HarnessRunResult deliver(String waitId, Object input) { + return deliverInternal(waitId, input, true); + } + + /** Runs up to {@code budget.maxExecutions} currently runnable Tasks. */ + public List runReady(HarnessRunBudget budget) { + HarnessRunBudget effective = budget == null ? HarnessRunBudget.builder().build() : budget; + int limit = effective.getMaxExecutions() <= 0 ? 1 : effective.getMaxExecutions(); + List results = new ArrayList(); + for (int i = 0; i < limit; i++) { + List runnable = gateway.listRunnableTasks(); + if (runnable.isEmpty()) { + break; + } + TaskRecord task = runnable.get(0); + HarnessRunRequest request = HarnessRunRequest.builder() + .taskId(task.getTaskId()) + .budget(effective) + .build(); + results.add(run(request)); + } + return results; + } + + private ExecutionRecord resolveExecution(HarnessRunRequest request, + AgentRequest agentRequest) { + String requestedExecutionId = trimToNull(request.getExecutionId()); + if (requestedExecutionId != null) { + ExecutionRecord execution = gateway.getExecution(requestedExecutionId); + if (execution == null) { + throw new HarnessValidationException("execution not found: " + requestedExecutionId); + } + String requestedScope = trimToNull(request.getScopeKey()); + if (requestedScope != null && !requestedScope.equals(trimToNull(execution.getScopeKey()))) { + throw new HarnessConflictException("execution does not belong to requested scope: " + + requestedExecutionId); + } + return execution; + } + String sessionId = firstNonBlank(request.getSessionId(), + agentRequest == null ? null : agentRequest.getMetadataString(AgentRequest.METADATA_KEY_SESSION_ID)); + String runId = agentRequest == null ? null + : agentRequest.getMetadataString(AgentRequest.METADATA_KEY_RUN_ID); + if (sessionId == null) { + sessionId = "session_" + UUID.randomUUID().toString().replace("-", ""); + } + return gateway.createExecution(HarnessExecutionSpec.builder() + .taskId(trimToNull(request.getTaskId())) + .scopeKey(trimToNull(request.getScopeKey())) + .sessionId(sessionId) + .runId(runId) + .inputSummary(inputSummary(agentRequest == null ? null : agentRequest.getInput())) + .idempotencyKey(trimToNull(request.getIdempotencyKey())) + .build(), actor); + } + + private HarnessRunResult executeExecution(ExecutionRecord source, + AgentRequest request, + HarnessRunBudget budget) { + if (source == null) { + throw new HarnessValidationException("execution is required"); + } + ReentrantLock executionLock = executionLock(source.getExecutionId()); + executionLock.lock(); + try { + // The caller may have read a READY copy before another local + // request completed the same Execution. Refresh under the mutex + // so the second request observes the committed terminal/waiting + // state instead of claiming and running it again. + ExecutionRecord current = gateway.getExecution(source.getExecutionId()); + if (current == null) { + throw new HarnessValidationException("execution not found: " + source.getExecutionId()); + } + return executeExecutionLocked(current, request, budget); + } finally { + executionLock.unlock(); + } + } + + private HarnessRunResult executeExecutionLocked(ExecutionRecord source, + AgentRequest request, + HarnessRunBudget budget) { + if (source.getStatus() == ExecutionStatus.WAITING) { + return result(HarnessRunStatus.WAITING, source, null, source.getOutputText(), + source.getWaitId(), source.getOperationId(), source.getError()); + } + if (source.getStatus() == ExecutionStatus.UNKNOWN) { + return result(HarnessRunStatus.UNKNOWN, source, task(source), source.getOutputText(), + source.getWaitId(), source.getOperationId(), source.getError()); + } + if (source.getStatus() == ExecutionStatus.SUCCEEDED) { + return result(HarnessRunStatus.COMPLETED, source, task(source), source.getOutputText(), + null, null, source.getError()); + } + if (source.getStatus() == ExecutionStatus.FAILED) { + return result(HarnessRunStatus.FAILED, source, task(source), source.getOutputText(), + null, null, source.getError()); + } + if (source.getStatus() == ExecutionStatus.CANCELLED) { + return result(HarnessRunStatus.CANCELLED, source, task(source), null, + null, null, source.getError()); + } + + HarnessRunBudget effectiveBudget = budget == null ? HarnessRunBudget.builder().build() : budget; + long leaseDuration = effectiveBudget.getLeaseDurationMillis() <= 0L + ? DEFAULT_LEASE_MILLIS : effectiveBudget.getLeaseDurationMillis(); + ExecutionRecord claimed = gateway.claimExecution(source.getExecutionId(), + effectiveBudget.getWorkerId() == null ? workerId : effectiveBudget.getWorkerId(), + leaseDuration); + String effectiveWorker = claimed.getWorkerId(); + ScheduledFuture heartbeat = scheduleHeartbeat(claimed, effectiveWorker, leaseDuration); + HarnessExecutionContext executionContext = new HarnessExecutionContext( + gateway, + claimed.getExecutionId(), + claimed.getTaskId(), + claimed.getSessionId(), + claimed.getScopeKey(), + claimed.getRunId(), + actor, + new HarnessExecutionContext.AsyncCompletionHandler() { + @Override + public void onCompletion(String waitId, + String operationId, + String invocationId, + Object value, + Throwable error) { + handleAsyncCompletion(claimed.getExecutionId(), waitId, operationId, + invocationId, value, error); + } + }); + return executeAdaptedExecution(claimed, executionContext, request, effectiveBudget, heartbeat); + } + + private ReentrantLock executionLock(String executionId) { + String key = required(executionId, "execution id"); + int hash = key.hashCode() & Integer.MAX_VALUE; + return executionLocks[hash % executionLocks.length]; + } + + private HarnessRunResult executeAdaptedExecution(ExecutionRecord claimed, + HarnessExecutionContext executionContext, + AgentRequest request, + HarnessRunBudget budget, + ScheduledFuture heartbeat) { + HarnessAdapterExecution adapterExecution = null; + HarnessExecutionAdapterSession adapterSession = null; + HarnessAdapterState adapterState = null; + ExecutionStatus executionStatus; + String outputText = null; + String errorText = null; + String waitId = null; + String operationId = null; + try { + HarnessAdapterState previousState = previousAdapterState(claimed); + adapterSession = executionAdapter.open( + executionContext, + budget, + previousState); + adapterExecution = adapterSession.run(prepareRequest(request, claimed)); + if (adapterExecution == null) { + throw new IllegalStateException("execution adapter returned no result"); + } + AgentExecutionStatus status = adapterExecution.getStatus() == null + ? AgentExecutionStatus.COMPLETED : adapterExecution.getStatus(); + executionStatus = mapStatus(status); + outputText = adapterExecution.getOutputText(); + errorText = adapterExecution.getError(); + waitId = adapterExecution.getWaitId(); + operationId = adapterExecution.getOperationId(); + if (executionStatus == ExecutionStatus.WAITING && waitId == null) { + WaitRecord wait = gateway.ensureWait(claimed.getExecutionId(), claimed.getTaskId(), + null, WaitType.EXTERNAL_EVENT, operationId, null, + Collections.singletonMap("source", "execution-adapter"), actor); + waitId = wait.getWaitId(); + } + } catch (AgentHostInputException inputException) { + WaitRecord wait = gateway.ensureWait(claimed.getExecutionId(), claimed.getTaskId(), + null, WaitType.USER_INPUT, null, null, + Collections.singletonMap("request", inputException.getRequest()), actor); + executionStatus = ExecutionStatus.WAITING; + waitId = wait.getWaitId(); + outputText = "HARNESS_USER_INPUT_REQUIRED: " + JSON.toJSONString(inputException.getRequest()); + } catch (AgentApprovalRequiredException approvalException) { + String toolName = approvalException.getRequest() == null + ? null : approvalException.getRequest().getToolName(); + String callId = approvalException.getRequest() == null + ? null : approvalException.getRequest().getCallId(); + WaitRecord wait = gateway.requestApproval(claimed.getExecutionId(), claimed.getTaskId(), + toolName, callId, approvalException.getRequest() == null + ? null : approvalException.getRequest().getArguments(), actor); + executionStatus = ExecutionStatus.WAITING; + waitId = wait.getWaitId(); + outputText = "HARNESS_APPROVAL_REQUIRED: waitId=" + waitId; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + executionStatus = ExecutionStatus.UNKNOWN; + errorText = "execution adapter interrupted; side effects require reconciliation"; + } catch (Exception failure) { + executionStatus = ExecutionStatus.FAILED; + errorText = failure.getMessage() == null ? failure.toString() : failure.getMessage(); + } finally { + if (adapterSession != null) { + try { + adapterState = adapterSession.snapshot(); + } catch (RuntimeException ignored) { + // The execution result remains authoritative when an + // adapter cannot export a late recovery snapshot. + } + try { + adapterSession.close(); + } catch (RuntimeException ignored) { + // Resource cleanup must not hide the durable outcome. + } + } + if (heartbeat != null) { + heartbeat.cancel(false); + } + } + + Map checkpointState = adapterExecution == null + ? new LinkedHashMap() + : new LinkedHashMap(adapterExecution.getCheckpointState() == null + ? Collections.emptyMap() : adapterExecution.getCheckpointState()); + if (adapterState == null && adapterExecution != null) { + adapterState = adapterExecution.getState(); + } + if (adapterState != null) { + HarnessAdapterState state = adapterState; + checkpointState.put("harnessAdapterType", state.getAdapterType()); + checkpointState.put("harnessAdapterState", JSON.parseObject(JSON.toJSONString(state))); + } + AgentSessionSnapshot legacySessionSnapshot = executionAdapter instanceof AgentHarnessExecutionAdapter + ? ((AgentHarnessExecutionAdapter) executionAdapter).sessionSnapshot(adapterState) : null; + String checkpointSummary = adapterExecution == null ? null : adapterExecution.getCheckpointSummary(); + HarnessExecutionOutcome outcome = HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(executionStatus) + .outputText(outputText) + .error(errorText) + .waitId(waitId) + .operationId(operationId) + .checkpointSummary(checkpointSummary == null + ? checkpointSummary(executionStatus, outputText, errorText) : checkpointSummary) + .checkpointState(checkpointState) + .sessionSnapshot(legacySessionSnapshot) + .build(); + ExecutionRecord persisted; + try { + persisted = gateway.persistExecutionOutcome(outcome); + } catch (RuntimeException persistenceFailure) { + ExecutionRecord durable = null; + try { + durable = gateway.markExecutionUnknownIfLeaseLost( + claimed.getExecutionId(), claimed.getLeaseId(), claimed.getFencingToken(), + claimed.getWorkerId(), "execution outcome could not be persisted: " + + persistenceFailure.getMessage()); + } catch (RuntimeException ignored) { + // A newer worker may have reclaimed the execution, or the + // store may be temporarily unavailable. The durable state is + // authoritative; report its current view below. + } + if (durable == null) { + durable = gateway.getExecution(claimed.getExecutionId()); + } + if (durable == null) { + return result(HarnessRunStatus.UNKNOWN, null, null, outputText, + waitId, operationId, persistenceFailure.getMessage()); + } + HarnessRunResult durableResult = resultForExecution(durable); + durableResult.setError(persistenceFailure.getMessage()); + return durableResult; + } + activateAsyncCompletions(executionContext); + String exposedOutput = persisted.getStatus() == ExecutionStatus.CANCELLED ? null : outputText; + HarnessRunResult completed = result(mapRunStatus(persisted.getStatus()), persisted, task(persisted), + exposedOutput, persisted.getWaitId(), persisted.getOperationId(), errorText); + Object adapterResult = adapterExecution == null ? null : adapterExecution.getResult(); + completed.setAdapterResult(adapterResult); + if (adapterResult instanceof AgentResult) { + completed.setAgentResult((AgentResult) adapterResult); + } + return completed; + } + + private HarnessAdapterState previousAdapterState(ExecutionRecord execution) { + HarnessAdapterState current = adapterStateFromExecution(execution); + if (current != null || execution == null || execution.getSessionId() == null) { + return current; + } + + // A new host message normally creates a new Execution. Its checkpoint + // is empty, but the adapter-owned session state belongs to the stable + // session identity rather than to that new Execution. Reuse the latest + // durable state for that session without binding the Task to it. + HarnessAdapterState latest = null; + long latestUpdatedAt = Long.MIN_VALUE; + for (ExecutionRecord candidate : gateway.listExecutions()) { + if (candidate == null || candidate.getExecutionId() == null + || candidate.getExecutionId().equals(execution.getExecutionId()) + || !execution.getSessionId().equals(candidate.getSessionId())) { + continue; + } + HarnessAdapterState candidateState = adapterStateFromExecution(candidate); + if (candidateState == null) { + continue; + } + if (latest == null || candidate.getUpdatedAtEpochMs() >= latestUpdatedAt) { + latest = candidateState; + latestUpdatedAt = candidate.getUpdatedAtEpochMs(); + } + } + return latest; + } + + private HarnessAdapterState adapterStateFromExecution(ExecutionRecord execution) { + if (execution == null || execution.getCheckpointId() == null) { + return null; + } + CheckpointRecord checkpoint = gateway.getCheckpoint(execution.getCheckpointId()); + if (checkpoint == null || checkpoint.getState() == null) { + return null; + } + Object raw = checkpoint.getState().get("harnessAdapterState"); + if (raw == null) { + return null; + } + HarnessAdapterState state = raw instanceof String + ? JSON.parseObject((String) raw, HarnessAdapterState.class) + : JSON.parseObject(JSON.toJSONString(raw), HarnessAdapterState.class); + if (state != null && state.getAdapterType() != null + && !state.getAdapterType().equals(executionAdapter.getAdapterType())) { + throw new HarnessConflictException("execution adapter type mismatch: " + state.getAdapterType()); + } + return state; + } + + private AgentRequest prepareRequest(AgentRequest request, ExecutionRecord execution) { + AgentRequest.AgentRequestBuilder builder = request == null + ? AgentRequest.builder() : request.toBuilder(); + Map metadata = request == null || request.getMetadata() == null + ? new LinkedHashMap() + : new LinkedHashMap(request.getMetadata()); + metadata.put(AgentRequest.METADATA_KEY_SESSION_ID, execution.getSessionId()); + metadata.put(AgentRequest.METADATA_KEY_RUN_ID, execution.getRunId()); + metadata.put(AgentRequest.METADATA_KEY_HARNESS_EXECUTION_ID, execution.getExecutionId()); + metadata.put(AgentRequest.METADATA_KEY_HARNESS_SCOPE, execution.getScopeKey()); + if (execution.getTaskId() != null) { + metadata.put(AgentRequest.METADATA_KEY_HARNESS_TASK_ID, execution.getTaskId()); + } + return builder.metadata(metadata).build(); + } + + private ScheduledFuture scheduleHeartbeat(final ExecutionRecord execution, + final String worker, + long duration) { + long interval = Math.max(100L, duration / 3L); + return heartbeatExecutor.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + try { + gateway.heartbeat(execution.getExecutionId(), execution.getLeaseId(), + execution.getFencingToken(), worker, duration); + } catch (RuntimeException ignored) { + // The next durable operation will surface the fencing or + // lease error and classify the execution as UNKNOWN. + } + } + }, interval, interval, TimeUnit.MILLISECONDS); + } + + private void activateAsyncCompletions(HarnessExecutionContext context) { + for (HarnessExecutionContext.AsyncCompletionRegistration registration + : context.drainAsyncCompletions()) { + if (registration == null || registration.getCompletion() == null) { + continue; + } + registration.getCompletion().whenComplete(new java.util.function.BiConsumer() { + @Override + public void accept(Object value, Throwable error) { + if (context.getAsyncCompletionHandler() != null) { + context.getAsyncCompletionHandler().onCompletion( + registration.getWaitId(), registration.getOperationId(), + registration.getInvocationId(), value, unwrap(error)); + } + } + }); + } + } + + private void handleAsyncCompletion(String executionId, + String waitId, + String operationId, + String invocationId, + Object value, + Throwable error) { + Object delivery = value; + String effectiveOperationId = operationId; + String effectiveWaitId = waitId; + String completionError = error == null ? null + : (error.getMessage() == null ? error.toString() : error.getMessage()); + if (value instanceof AgentToolResult) { + AgentToolResult result = (AgentToolResult) value; + delivery = result.getOutput(); + if (effectiveOperationId == null) effectiveOperationId = result.getOperationId(); + if (effectiveWaitId == null) effectiveWaitId = result.getWaitId(); + if (completionError == null) completionError = result.getError(); + } + if (error != null) { + Map failure = new LinkedHashMap(); + failure.put("status", "FAILED"); + failure.put("error", completionError); + delivery = failure; + } + if (invocationId != null) { + try { + contextCompleteToolInvocation(invocationId, effectiveOperationId, effectiveWaitId, + value, delivery, completionError); + } catch (RuntimeException failure) { + recordAsyncFailure(executionId, effectiveWaitId, effectiveOperationId, + invocationId, failure); + return; + } + } + if (effectiveWaitId == null) { + return; + } + final String continuationOperationId = effectiveOperationId; + final String continuationInvocationId = invocationId; + try { + WaitRecord wait = gateway.getWait(effectiveWaitId); + if (wait == null || !executionId.equals(wait.getExecutionId())) { + recordAsyncFailure(executionId, effectiveWaitId, continuationOperationId, + invocationId, new HarnessValidationException("async completion wait is unavailable")); + return; + } + if (!WaitStatus.OPEN.equals(wait.getStatus())) { + if (invocationId == null) { + recordQuarantinedAsyncCompletion(executionId, effectiveWaitId, + continuationOperationId, null, wait, delivery, completionError); + } + return; + } + final Object deliveredValue = delivery; + final String deliveredWaitId = effectiveWaitId; + continuationExecutor.execute(new Runnable() { + @Override + public void run() { + try { + HarnessRunResult result = deliverInternal(deliveredWaitId, deliveredValue, autoResume); + notifyAsyncResult(result, executionId, deliveredWaitId); + } catch (RuntimeException failure) { + recordAsyncFailure(executionId, deliveredWaitId, continuationOperationId, + continuationInvocationId, failure); + } + } + }); + } catch (RuntimeException ignored) { + recordAsyncFailure(executionId, effectiveWaitId, continuationOperationId, + invocationId, ignored); + } + } + + /** + * Persists the terminal result of an asynchronous business tool before its + * wait is delivered. A failed CompletionStage is classified UNKNOWN: + * the future failed, but that alone cannot prove that the remote side did + * not apply the side effect. + */ + private void contextCompleteToolInvocation(String invocationId, + String operationId, + String waitId, + Object rawResult, + Object delivery, + String error) { + ToolInvocationStatus status; + String output; + String effectiveError = error; + if (rawResult instanceof AgentToolResult) { + AgentToolResult result = (AgentToolResult) rawResult; + status = completionLedgerStatus(result, error); + output = result.getOutput(); + if (effectiveError == null) { + effectiveError = result.getError(); + } + if (operationId == null) { + operationId = result.getOperationId(); + } + if (waitId == null) { + waitId = result.getWaitId(); + } + } else { + status = error == null ? ToolInvocationStatus.SUCCEEDED : ToolInvocationStatus.UNKNOWN; + output = error == null ? stringify(rawResult) : stringify(delivery); + } + gateway.completeToolInvocation(invocationId, status, operationId, waitId, + output, effectiveError, actor); + } + + private ToolInvocationStatus completionLedgerStatus(AgentToolResult result, + String completionError) { + if (completionError != null) { + return ToolInvocationStatus.UNKNOWN; + } + if (result == null || AgentToolExecutionStatus.COMPLETED.equals(result.getStatus())) { + return result != null && result.isFailed() + ? ToolInvocationStatus.FAILED : ToolInvocationStatus.SUCCEEDED; + } + if (AgentToolExecutionStatus.UNKNOWN.equals(result.getStatus())) { + return ToolInvocationStatus.UNKNOWN; + } + if (AgentToolExecutionStatus.FAILED.equals(result.getStatus()) || result.isFailed()) { + return ToolInvocationStatus.FAILED; + } + // A CompletionStage that completes with another WAITING result is + // malformed. Do not turn it into a retryable success. + return ToolInvocationStatus.UNKNOWN; + } + + /** Completes a pending invocation when a host directly delivers its Wait. */ + private void completePendingToolInvocation(WaitRecord wait, Object input) { + if (wait == null || wait.getPayload() == null) { + return; + } + Object rawInvocationId = wait.getPayload() + .get(AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID); + String invocationId = trimToNull(rawInvocationId == null ? null : String.valueOf(rawInvocationId)); + if (invocationId == null) { + return; + } + ToolInvocationRecord existing = gateway.getToolInvocationInScope(invocationId, + executionScope(wait)); + if (existing == null || isTerminalInvocation(existing.getStatus())) { + return; + } + if (WaitType.APPROVAL.equals(wait.getType())) { + if (!gateway.deliveryApproved(input)) { + gateway.completeToolInvocation(invocationId, ToolInvocationStatus.FAILED, + wait.getOperationId(), wait.getWaitId(), + "HARNESS_APPROVAL_DENIED", "approval was denied", actor); + } + // An approved invocation remains WAITING until the resumed Agent + // re-reserves it. That transition is the single retry authority. + return; + } + AgentToolResult result = input instanceof AgentToolResult + ? (AgentToolResult) input : null; + ToolInvocationStatus status = result == null + ? ToolInvocationStatus.SUCCEEDED : completionLedgerStatus(result, null); + String output = result == null ? stringify(input) : result.getOutput(); + String error = result == null ? null : result.getError(); + String operationId = result == null ? wait.getOperationId() : result.getOperationId(); + String waitId = result == null ? wait.getWaitId() : firstNonBlank(result.getWaitId(), wait.getWaitId()); + gateway.completeToolInvocation(invocationId, status, operationId, waitId, + output, error, actor); + } + + private String executionScope(WaitRecord wait) { + if (wait == null || wait.getExecutionId() == null) { + return null; + } + ExecutionRecord execution = gateway.getExecution(wait.getExecutionId()); + return execution == null ? null : execution.getScopeKey(); + } + + private boolean isTerminalInvocation(ToolInvocationStatus status) { + return ToolInvocationStatus.SUCCEEDED.equals(status) + || ToolInvocationStatus.FAILED.equals(status) + || ToolInvocationStatus.UNKNOWN.equals(status) + || ToolInvocationStatus.CANCELLED.equals(status); + } + + private void recordQuarantinedAsyncCompletion(String executionId, + String waitId, + String operationId, + String invocationId, + WaitRecord wait, + Object delivery, + String error) { + try { + gateway.recordEvent("async.completion_quarantined", executionId, + mapOf("waitId", waitId, + "operationId", operationId, + "invocationId", invocationId, + "waitStatus", wait == null ? null : wait.getStatus(), + "delivery", delivery, + "error", error, + "lateCompletion", true, + "sideEffectStatus", "unknown"), actor); + } catch (RuntimeException ignored) { + // A closed or unavailable store must not execute a late callback. + } + } + + private void notifyAsyncResult(HarnessRunResult result, + String executionId, + String waitId) { + if (listener == null || result == null) { + return; + } + try { + listener.onResult(result); + } catch (RuntimeException listenerFailure) { + recordAsyncFailure(executionId, waitId, null, null, listenerFailure); + } + } + + private void recordAsyncFailure(String executionId, + String waitId, + String operationId, + String invocationId, + RuntimeException failure) { + try { + gateway.recordEvent("async.completion_delivery_failed", executionId, + mapOf("waitId", waitId, "operationId", operationId, + "invocationId", invocationId, + "error", failure == null ? null : failure.getMessage()), actor); + } catch (RuntimeException ignored) { + // The Harness may already be closing; the durable execution state + // remains authoritative when event recording is unavailable. + } + } + + private HarnessRunResult deliverInternal(String waitId, Object input, boolean resume) { + String id = required(waitId, "wait id"); + WaitRecord initialWait = gateway.getWait(id); + if (initialWait == null) { + throw new HarnessValidationException("wait not found: " + id); + } + ExecutionRecord initialExecution = gateway.getExecution(initialWait.getExecutionId()); + if (initialExecution == null) { + throw new HarnessValidationException("execution not found for wait: " + id); + } + ReentrantLock lock = executionLock(initialExecution.getExecutionId()); + lock.lock(); + try { + // Re-read under the same local mutex used by run/resume. This + // prevents two callbacks for the same wait from both opening and + // running the same Agent slice. + WaitRecord wait = gateway.getWait(id); + ExecutionRecord execution = wait == null ? null : gateway.getExecution(wait.getExecutionId()); + if (wait == null) { + throw new HarnessValidationException("wait not found: " + id); + } + if (execution == null) { + throw new HarnessValidationException("execution not found for wait: " + id); + } + if (WaitStatus.DELIVERED.equals(wait.getStatus())) { + // Gateway delivery is idempotent. A repeated delivery must + // remain a read of the committed state and must never turn a + // READY continuation into a second Agent run. + return resultForExecution(execution); + } + if (!WaitStatus.OPEN.equals(wait.getStatus())) { + throw new HarnessConflictException("wait is not open: " + id); + } + + completePendingToolInvocation(wait, input); + + boolean replaceToolResult; + if (executionAdapter != null) { + HarnessAdapterState previousState = previousAdapterState(execution); + HarnessAdapterDelivery delivery = executionAdapter.applyDelivery(previousState, wait, input); + replaceToolResult = delivery != null && delivery.isReplacedPendingResult(); + HarnessAdapterState deliveredState = delivery == null ? previousState : delivery.getState(); + AgentSessionSnapshot legacySessionSnapshot = executionAdapter instanceof AgentHarnessExecutionAdapter + ? ((AgentHarnessExecutionAdapter) executionAdapter).sessionSnapshot(deliveredState) : null; + gateway.deliverAdapterWait(id, input, deliveredState, legacySessionSnapshot, actor); + } else { + AgentSessionSnapshot snapshot = execution.getSessionId() == null + ? null : gateway.getSessionSnapshot(execution.getSessionId()); + replaceToolResult = applyDelivery(snapshot, wait, input); + gateway.deliverWait(id, input, snapshot, actor); + } + return continueAfterDelivery(execution, wait, input, replaceToolResult, resume); + } finally { + lock.unlock(); + } + } + + private HarnessRunResult continueAfterDelivery(ExecutionRecord previousExecution, + WaitRecord wait, + Object input, + boolean replaceToolResult, + boolean resume) { + ExecutionRecord updated = gateway.getExecution(previousExecution.getExecutionId()); + if (updated == null) { + throw new HarnessValidationException("execution not found after wait delivery: " + + previousExecution.getExecutionId()); + } + if (ExecutionStatus.WAITING.equals(updated.getStatus()) + || ExecutionStatus.CANCELLED.equals(updated.getStatus()) + || ExecutionStatus.SUCCEEDED.equals(updated.getStatus()) + || ExecutionStatus.FAILED.equals(updated.getStatus()) + || ExecutionStatus.UNKNOWN.equals(updated.getStatus())) { + return resultForExecution(updated); + } + if (!resume) { + return result(HarnessRunStatus.CONTINUATION_REQUIRED, updated, task(updated), null, + updated.getWaitId(), updated.getOperationId(), + replaceToolResult ? null : "wake delivered without a replaceable tool result"); + } + HarnessRunRequest resumeRequest = HarnessRunRequest.builder() + .executionId(updated.getExecutionId()) + .build(); + if (!replaceToolResult && (WaitType.USER_INPUT.equals(wait.getType()) + || WaitType.EXTERNAL_EVENT.equals(wait.getType()))) { + resumeRequest = HarnessRunRequest.builder() + .executionId(updated.getExecutionId()) + .input(input) + .build(); + } + return run(resumeRequest); + } + + private HarnessRunResult resultForExecution(ExecutionRecord execution) { + if (execution == null) { + return result(HarnessRunStatus.FAILED, null, null, null, null, null, + "execution is unavailable"); + } + HarnessRunStatus status = ExecutionStatus.RUNNING.equals(execution.getStatus()) + ? HarnessRunStatus.CONTINUATION_REQUIRED : mapRunStatus(execution.getStatus()); + String output = ExecutionStatus.CANCELLED.equals(execution.getStatus()) + ? null : execution.getOutputText(); + return result(status, execution, task(execution), output, + execution.getWaitId(), execution.getOperationId(), execution.getError()); + } + + private boolean applyDelivery(AgentSessionSnapshot snapshot, + WaitRecord wait, + Object input) { + if (snapshot == null || wait == null || wait.getPayload() == null) { + return false; + } + Object rawCallId = wait.getPayload().get(AgentToolCall.METADATA_KEY_PARENT_CALL_ID); + if (rawCallId == null || String.valueOf(rawCallId).trim().isEmpty()) { + rawCallId = wait.getPayload().get("callId"); + } + if (rawCallId == null || String.valueOf(rawCallId).trim().isEmpty() + || snapshot.getMemory() == null) { + return false; + } + String output; + if (WaitType.APPROVAL.equals(wait.getType())) { + output = gateway.deliveryApproved(input) + ? "HARNESS_APPROVAL_GRANTED: retry the approved tool call. delivery=" + + stringify(input) + : "HARNESS_APPROVAL_DENIED: do not retry the denied tool call. delivery=" + + stringify(input); + } else { + output = stringify(input); + } + MemorySnapshot memory = snapshot.getMemory(); + List items = memory == null ? null : memory.getItems(); + if (items == null) { + return false; + } + for (Object item : items) { + if (replaceFunctionCallOutput(item, String.valueOf(rawCallId), output) + || replaceCodeActMarker(item, String.valueOf(rawCallId), output)) { + memory.setItems(items); + snapshot.setMemory(memory); + return true; + } + } + return false; + } + + private boolean replaceFunctionCallOutput(Object item, String callId, String output) { + if (!(item instanceof Map)) { + return false; + } + Map candidate = (Map) item; + if (!"function_call_output".equals(String.valueOf(candidate.get("type"))) + || !callId.equals(String.valueOf(candidate.get("call_id")))) { + return false; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) item; + mutable.put("output", output); + return true; + } + + private boolean replaceCodeActMarker(Object item, String callId, String output) { + if (!(item instanceof Map)) { + return false; + } + Map message = (Map) item; + if (!"message".equals(String.valueOf(message.get("type"))) + || !"system".equals(String.valueOf(message.get("role")))) { + return false; + } + Object rawContent = message.get("content"); + if (!(rawContent instanceof List)) { + return false; + } + for (Object block : (List) rawContent) { + if (!(block instanceof Map)) { + continue; + } + Map content = (Map) block; + Object rawText = content.get("text"); + if (rawText == null || !String.valueOf(rawText) + .startsWith(AgentToolCall.CODEACT_PENDING_RESULT_PREFIX)) { + continue; + } + String markerJson = String.valueOf(rawText) + .substring(AgentToolCall.CODEACT_PENDING_RESULT_PREFIX.length()).trim(); + try { + JSONObject marker = JSON.parseObject(markerJson); + if (marker == null || !callId.equals(String.valueOf(marker.get("callId")))) { + continue; + } + } catch (RuntimeException ignored) { + continue; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) block; + mutable.put("text", "CODE_RESULT: " + stringify(output)); + return true; + } + return false; + } + + private TaskRecord task(ExecutionRecord execution) { + return execution == null || execution.getTaskId() == null + ? null : gateway.getTask(execution.getTaskId()); + } + + private HarnessRunResult result(HarnessRunStatus status, + ExecutionRecord execution, + TaskRecord task, + String output, + String waitId, + String operationId, + String error) { + return HarnessRunResult.builder() + .status(status) + .execution(execution == null ? null : execution.copy()) + .task(task == null ? null : task.copy()) + .agentResult(null) + .outputText(output) + .waitId(waitId) + .operationId(operationId) + .error(error) + .build(); + } + + private ExecutionStatus mapStatus(AgentExecutionStatus status) { + if (status == AgentExecutionStatus.WAITING) return ExecutionStatus.WAITING; + if (status == AgentExecutionStatus.CONTINUATION_REQUIRED) return ExecutionStatus.READY; + if (status == AgentExecutionStatus.FAILED) return ExecutionStatus.FAILED; + return ExecutionStatus.SUCCEEDED; + } + + private HarnessRunStatus mapRunStatus(ExecutionStatus status) { + if (status == ExecutionStatus.WAITING) return HarnessRunStatus.WAITING; + if (status == ExecutionStatus.READY) return HarnessRunStatus.CONTINUATION_REQUIRED; + if (status == ExecutionStatus.SUCCEEDED) return HarnessRunStatus.COMPLETED; + if (status == ExecutionStatus.UNKNOWN) return HarnessRunStatus.UNKNOWN; + if (status == ExecutionStatus.CANCELLED) return HarnessRunStatus.CANCELLED; + return HarnessRunStatus.FAILED; + } + + private Map checkpointState(AgentResult result, + ExecutionStatus status, + String waitId, + String operationId) { + Map state = new LinkedHashMap(); + state.put("executionStatus", status == null ? null : status.name()); + state.put("waitId", waitId); + state.put("operationId", operationId); + if (result != null) { + state.put("agentStatus", result.getExecutionStatus() == null + ? null : result.getExecutionStatus().name()); + state.put("steps", result.getSteps()); + state.put("runId", result.getRunId()); + state.put("sessionId", result.getSessionId()); + } + return state; + } + + private String checkpointSummary(ExecutionStatus status, String output, String error) { + if (error != null) return "Agent slice failed: " + error; + if (status == ExecutionStatus.WAITING) return "Agent slice is waiting for a durable wakeup"; + if (status == ExecutionStatus.READY) return "Agent slice reached its boundary and can continue"; + return output == null ? "Agent slice completed" : "Agent slice completed: " + output; + } + + private String inputSummary(Object input) { + if (input == null) return null; + String value = String.valueOf(input); + return value.length() > 500 ? value.substring(0, 500) : value; + } + + private String stringify(Object value) { + return value instanceof String ? (String) value : JSON.toJSONString(value); + } + + private Map mapOf(Object... values) { + Map result = new LinkedHashMap(); + if (values == null) { + return result; + } + for (int i = 0; i + 1 < values.length; i += 2) { + result.put(String.valueOf(values[i]), values[i + 1]); + } + return result; + } + + private Throwable unwrap(Throwable error) { + if (error instanceof java.util.concurrent.CompletionException + && error.getCause() != null) return error.getCause(); + return error; + } + + private String firstNonBlank(String first, String second) { + return trimToNull(first) == null ? trimToNull(second) : trimToNull(first); + } + + private String required(String value, String label) { + String normalized = trimToNull(value); + if (normalized == null) throw new HarnessValidationException(label + " is required"); + return normalized; + } + + private String trimToNull(String value) { + if (value == null) return null; + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + @Override + public void close() { + heartbeatExecutor.shutdownNow(); + continuationExecutor.shutdownNow(); + gateway.close(); + if (persistence != null && persistence.getStore() == store) { + persistence.close(); + } + if (executionAdapter != null) { + executionAdapter.close(); + } + } + + public static final class Builder { + private Agent agent; + private HarnessExecutionAdapter executionAdapter; + private HarnessStore store; + private HarnessPersistence persistence; + private HarnessContract contract; + private HarnessActor actor; + private String workerId; + private boolean autoResume = true; + private HarnessRunListener listener; + + public Builder agent(Agent value) { this.agent = value; return this; } + public Builder executionAdapter(HarnessExecutionAdapter value) { this.executionAdapter = value; return this; } + public Builder store(HarnessStore value) { this.store = value; return this; } + public Builder persistence(HarnessPersistence value) { this.persistence = value; return this; } + public Builder contract(HarnessContract value) { this.contract = value; return this; } + public Builder actor(HarnessActor value) { this.actor = value; return this; } + public Builder workerId(String value) { this.workerId = value; return this; } + public Builder autoResume(boolean value) { this.autoResume = value; return this; } + public Builder listener(HarnessRunListener value) { this.listener = value; return this; } + + public AgentHarness build() { return new AgentHarness(this); } + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/AgentHarnessExecutionAdapter.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/AgentHarnessExecutionAdapter.java new file mode 100644 index 00000000..5aa7fa13 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/AgentHarnessExecutionAdapter.java @@ -0,0 +1,341 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import io.github.lnyocly.ai4j.agent.Agent; +import io.github.lnyocly.ai4j.agent.AgentContext; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; +import io.github.lnyocly.ai4j.agent.AgentOptions; +import io.github.lnyocly.ai4j.agent.AgentRequest; +import io.github.lnyocly.ai4j.agent.AgentResult; +import io.github.lnyocly.ai4j.agent.AgentSession; +import io.github.lnyocly.ai4j.agent.memory.MemorySnapshot; +import io.github.lnyocly.ai4j.agent.session.AgentSessionSnapshot; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Default adapter that runs the existing {@link Agent} runtime inside the + * generic Harness adapter protocol. + */ +public final class AgentHarnessExecutionAdapter implements HarnessExecutionAdapter { + + public static final String ADAPTER_TYPE = "ai4j-agent"; + private static final String SESSION_SNAPSHOT = "agentSessionSnapshot"; + + private final Agent agent; + + public AgentHarnessExecutionAdapter(Agent agent) { + if (agent == null) { + throw new IllegalArgumentException("agent is required"); + } + this.agent = agent; + } + + public Agent getAgent() { + return agent; + } + + @Override + public String getAdapterType() { + return ADAPTER_TYPE; + } + + @Override + public HarnessExecutionAdapterSession open(HarnessExecutionContext executionContext, + HarnessRunBudget budget, + HarnessAdapterState previousState) { + AgentContext context = createContext(executionContext, budget); + AgentSessionSnapshot snapshot = decodeSessionSnapshot(previousState); + if (snapshot == null && executionContext.getSessionId() != null) { + snapshot = executionContext.getGateway().getSessionSnapshot( + executionContext.getSessionId()); + } + AgentSession session = snapshot == null + ? agent.newSessionWithIdentity(executionContext.getSessionId(), + executionContext.getRunId(), context) + : agent.newSession(snapshot, context); + return new Session(executionContext, session); + } + + @Override + public HarnessAdapterDelivery applyDelivery(HarnessAdapterState state, + WaitRecord wait, + Object input) { + AgentSessionSnapshot snapshot = decodeSessionSnapshot(state); + if (snapshot == null) { + return HarnessAdapterDelivery.builder() + .state(state) + .replacedPendingResult(false) + .build(); + } + String callId = callId(wait); + if (callId == null || snapshot.getMemory() == null) { + return HarnessAdapterDelivery.builder() + .state(state) + .replacedPendingResult(false) + .build(); + } + String output = WaitType.APPROVAL.equals(wait == null ? null : wait.getType()) + ? approvalDeliveryOutput(input) + : stringify(input); + MemorySnapshot memory = snapshot.getMemory(); + List items = memory.getItems(); + if (items == null) { + return HarnessAdapterDelivery.builder().state(state).build(); + } + boolean replaced = false; + for (Object item : items) { + if (replaceFunctionCallOutput(item, callId, output) + || replaceCodeActMarker(item, callId, output)) { + replaced = true; + break; + } + } + if (replaced) { + memory.setItems(items); + snapshot.setMemory(memory); + } + return HarnessAdapterDelivery.builder() + .state(withSessionSnapshot(state, snapshot)) + .replacedPendingResult(replaced) + .build(); + } + + /** Compatibility bridge for callers that still inspect the old snapshot API. */ + public AgentSessionSnapshot sessionSnapshot(HarnessAdapterState state) { + return decodeSessionSnapshot(state); + } + + private AgentContext createContext(HarnessExecutionContext executionContext, + HarnessRunBudget budget) { + AgentContext base = agent.getContext(); + if (base == null) { + throw new IllegalStateException("agent context is required"); + } + AgentOptions options = base.getOptions() == null + ? AgentOptions.builder().build() : base.getOptions(); + AgentOptions.AgentOptionsBuilder optionsBuilder = options.toBuilder(); + if (budget != null) { + if (budget.getMaxSteps() > 0) { + optionsBuilder.maxSteps(budget.getMaxSteps()); + } + if (budget.getMaxWallTimeMillis() > 0L) { + optionsBuilder.wallClockTimeoutMillis(budget.getMaxWallTimeMillis()); + } + if (budget.getMaxTokenBudget() >= 0L) { + optionsBuilder.maxTokenBudget(budget.getMaxTokenBudget()); + } + } + ToolExecutor businessExecutor = base.getToolExecutor(); + return base.toBuilder() + .toolRegistry(new HarnessToolRegistry(base.getToolRegistry())) + .toolExecutor(new HarnessToolExecutor(executionContext, businessExecutor)) + .toolInterceptor(new HarnessToolInterceptor(base.getToolInterceptor())) + .options(optionsBuilder.build()) + .sessionId(executionContext.getSessionId()) + .systemPrompt(appendPrompt(base.getSystemPrompt(), HarnessPrompts.instructions())) + .build(); + } + + private AgentSessionSnapshot decodeSessionSnapshot(HarnessAdapterState state) { + if (state == null || state.getPayload() == null) { + return null; + } + Object raw = state.getPayload().get(SESSION_SNAPSHOT); + if (raw == null) { + return null; + } + if (raw instanceof AgentSessionSnapshot) { + return HarnessJson.copy((AgentSessionSnapshot) raw, AgentSessionSnapshot.class); + } + return JSON.parseObject(JSON.toJSONString(raw), AgentSessionSnapshot.class); + } + + private HarnessAdapterState withSessionSnapshot(HarnessAdapterState state, + AgentSessionSnapshot snapshot) { + Map payload = state == null || state.getPayload() == null + ? new LinkedHashMap() + : new LinkedHashMap(state.getPayload()); + payload.put(SESSION_SNAPSHOT, snapshot); + return HarnessAdapterState.builder() + .adapterType(ADAPTER_TYPE) + .sessionId(snapshot == null ? state == null ? null : state.getSessionId() : snapshot.getSessionId()) + .payload(payload) + .build(); + } + + private String callId(WaitRecord wait) { + if (wait == null || wait.getPayload() == null) { + return null; + } + Object raw = wait.getPayload().get(AgentToolCall.METADATA_KEY_PARENT_CALL_ID); + if (raw == null || String.valueOf(raw).trim().isEmpty()) { + raw = wait.getPayload().get("callId"); + } + return raw == null || String.valueOf(raw).trim().isEmpty() ? null : String.valueOf(raw); + } + + private boolean replaceFunctionCallOutput(Object item, String callId, String output) { + if (!(item instanceof Map)) { + return false; + } + Map candidate = (Map) item; + if (!"function_call_output".equals(String.valueOf(candidate.get("type"))) + || !callId.equals(String.valueOf(candidate.get("call_id")))) { + return false; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) item; + mutable.put("output", output); + return true; + } + + private boolean replaceCodeActMarker(Object item, String callId, String output) { + if (!(item instanceof Map)) { + return false; + } + Map message = (Map) item; + if (!"message".equals(String.valueOf(message.get("type"))) + || !"system".equals(String.valueOf(message.get("role")))) { + return false; + } + Object rawContent = message.get("content"); + if (!(rawContent instanceof List)) { + return false; + } + for (Object block : (List) rawContent) { + if (!(block instanceof Map)) { + continue; + } + Map content = (Map) block; + Object rawText = content.get("text"); + if (rawText == null || !String.valueOf(rawText) + .startsWith(AgentToolCall.CODEACT_PENDING_RESULT_PREFIX)) { + continue; + } + String markerJson = String.valueOf(rawText) + .substring(AgentToolCall.CODEACT_PENDING_RESULT_PREFIX.length()).trim(); + try { + JSONObject marker = JSON.parseObject(markerJson); + if (marker == null || !callId.equals(String.valueOf(marker.get("callId")))) { + continue; + } + } catch (RuntimeException ignored) { + continue; + } + @SuppressWarnings("unchecked") + Map mutable = (Map) block; + mutable.put("text", "CODE_RESULT: " + stringify(output)); + return true; + } + return false; + } + + private String appendPrompt(String base, String addition) { + if (base == null || base.trim().isEmpty()) { + return addition; + } + if (addition == null || addition.trim().isEmpty()) { + return base; + } + return base + "\n" + addition; + } + + private String stringify(Object value) { + return value instanceof String ? (String) value : JSON.toJSONString(value); + } + + private String approvalDeliveryOutput(Object input) { + return executionContextApprovalGranted(input) + ? "HARNESS_APPROVAL_GRANTED: retry the approved tool call. delivery=" + stringify(input) + : "HARNESS_APPROVAL_DENIED: do not retry the denied tool call. delivery=" + stringify(input); + } + + private boolean executionContextApprovalGranted(Object input) { + if (input instanceof Boolean) { + return ((Boolean) input).booleanValue(); + } + if (input instanceof Map) { + Map map = (Map) input; + Object approved = map.get("approved"); + if (approved instanceof Boolean) { + return ((Boolean) approved).booleanValue(); + } + Object decision = map.get("decision"); + return decision != null && isApprovalWord(String.valueOf(decision)); + } + return input != null && isApprovalWord(String.valueOf(input)); + } + + private boolean isApprovalWord(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(); + return "true".equals(normalized) || "yes".equals(normalized) + || "approve".equals(normalized) || "approved".equals(normalized) + || "allow".equals(normalized) || "allowed".equals(normalized) + || "同意".equals(normalized) || "批准".equals(normalized) + || "允许".equals(normalized); + } + + private final class Session implements HarnessExecutionAdapterSession { + private final HarnessExecutionContext executionContext; + private final AgentSession session; + + private Session(HarnessExecutionContext executionContext, AgentSession session) { + this.executionContext = executionContext; + this.session = session; + } + + @Override + public HarnessAdapterExecution run(AgentRequest request) throws Exception { + AgentResult result = session.run(request); + AgentExecutionStatus status = result == null || result.getExecutionStatus() == null + ? AgentExecutionStatus.FAILED : result.getExecutionStatus(); + return HarnessAdapterExecution.builder() + .status(status) + .outputText(result == null ? null : result.getOutputText()) + .waitId(result == null ? null : result.getWaitId()) + .operationId(result == null ? null : result.getOperationId()) + .checkpointSummary(checkpointSummary(status, result)) + .checkpointState(checkpointState(status, result)) + .result(result) + .build(); + } + + @Override + public HarnessAdapterState snapshot() { + return withSessionSnapshot(HarnessAdapterState.builder() + .adapterType(ADAPTER_TYPE) + .sessionId(executionContext.getSessionId()) + .payload(new LinkedHashMap()) + .build(), session.snapshot()); + } + } + + private String checkpointSummary(AgentExecutionStatus status, AgentResult result) { + if (status == AgentExecutionStatus.WAITING) { + return "Agent slice is waiting for a durable wakeup"; + } + if (status == AgentExecutionStatus.CONTINUATION_REQUIRED) { + return "Agent slice reached its boundary and can continue"; + } + return result == null || result.getOutputText() == null + ? "Agent slice completed" : "Agent slice completed: " + result.getOutputText(); + } + + private Map checkpointState(AgentExecutionStatus status, AgentResult result) { + Map state = new LinkedHashMap(); + state.put("adapterType", ADAPTER_TYPE); + state.put("agentStatus", status == null ? null : status.name()); + if (result != null) { + state.put("steps", result.getSteps()); + state.put("runId", result.getRunId()); + state.put("sessionId", result.getSessionId()); + } + return state; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/CheckpointRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/CheckpointRecord.java new file mode 100644 index 00000000..2280e242 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/CheckpointRecord.java @@ -0,0 +1,31 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class CheckpointRecord { + + private String checkpointId; + private String executionId; + private String taskId; + private String sessionId; + private String runId; + private String summary; + private long createdAtEpochMs; + + @Builder.Default + private Map state = new LinkedHashMap(); + + public CheckpointRecord copy() { + return HarnessJson.copy(this, CheckpointRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/DecisionRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/DecisionRecord.java new file mode 100644 index 00000000..d60a5a02 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/DecisionRecord.java @@ -0,0 +1,38 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class DecisionRecord { + + private String decisionId; + private String scopeKey; + private String taskId; + private String question; + private String chosenOption; + private String rationale; + private DecisionStatus status; + private HarnessActor proposer; + private HarnessActor arbiter; + private long createdAtEpochMs; + private long resolvedAtEpochMs; + + @Builder.Default + private List factIds = new ArrayList(); + + @Builder.Default + private List evidenceIds = new ArrayList(); + + public DecisionRecord copy() { + return HarnessJson.copy(this, DecisionRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/DecisionStatus.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/DecisionStatus.java new file mode 100644 index 00000000..2cb45940 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/DecisionStatus.java @@ -0,0 +1,7 @@ +package io.github.lnyocly.ai4j.harness; + +public enum DecisionStatus { + PROPOSED, + ACCEPTED, + REJECTED +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/EntityKind.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/EntityKind.java new file mode 100644 index 00000000..1335bc74 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/EntityKind.java @@ -0,0 +1,13 @@ +package io.github.lnyocly.ai4j.harness; + +public enum EntityKind { + TASK, + FACT, + DECISION, + EXECUTION, + EVIDENCE, + CHECKPOINT, + WAIT, + REVIEW, + SUBMISSION +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/EvidenceRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/EvidenceRecord.java new file mode 100644 index 00000000..a8f2788d --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/EvidenceRecord.java @@ -0,0 +1,28 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class EvidenceRecord { + + private String evidenceId; + private String scopeKey; + private String taskId; + private String executionId; + private String kind; + private String location; + private String summary; + private String contentRef; + private long createdAtEpochMs; + private HarnessProvenance provenance; + + public EvidenceRecord copy() { + return HarnessJson.copy(this, EvidenceRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ExecutionRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ExecutionRecord.java new file mode 100644 index 00000000..88fc4ed9 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ExecutionRecord.java @@ -0,0 +1,39 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionRecord { + + private String executionId; + private String taskId; + private String scopeKey; + private String sessionId; + private String runId; + private ExecutionStatus status; + private int attempt; + private String workerId; + private String leaseId; + private long fencingToken; + private String waitId; + private String operationId; + private String checkpointId; + private String outputText; + private String error; + private String inputSummary; + private long createdAtEpochMs; + private long startedAtEpochMs; + private long finishedAtEpochMs; + private long updatedAtEpochMs; + private long version; + + public ExecutionRecord copy() { + return HarnessJson.copy(this, ExecutionRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ExecutionStatus.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ExecutionStatus.java new file mode 100644 index 00000000..21c90792 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ExecutionStatus.java @@ -0,0 +1,11 @@ +package io.github.lnyocly.ai4j.harness; + +public enum ExecutionStatus { + READY, + RUNNING, + WAITING, + SUCCEEDED, + FAILED, + UNKNOWN, + CANCELLED +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FactRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FactRecord.java new file mode 100644 index 00000000..e77f1a26 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FactRecord.java @@ -0,0 +1,40 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class FactRecord { + + private String factId; + private String scopeKey; + private String taskId; + private String statement; + private String source; + private String confidence; + private boolean valid; + private String invalidatedBy; + private long createdAtEpochMs; + private long invalidatedAtEpochMs; + private HarnessProvenance provenance; + + @Builder.Default + private List evidenceIds = new ArrayList(); + + @Builder.Default + private Map metadata = new LinkedHashMap(); + + public FactRecord copy() { + return HarnessJson.copy(this, FactRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FileHarnessConfig.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FileHarnessConfig.java new file mode 100644 index 00000000..aa58e2a9 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FileHarnessConfig.java @@ -0,0 +1,33 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.nio.file.Path; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class FileHarnessConfig { + + private Path directory; + + @Builder.Default + private String harnessId = "default"; + + /** + * Once the durable snapshot is safely written, the transient journal can + * be replaced when it exceeds this size. A non-positive value disables + * compaction for operators that explicitly need an untrimmed journal. + */ + @Builder.Default + private long journalCompactionBytes = 8L * 1024L * 1024L; + + public FileHarnessConfig(Path directory, String harnessId) { + this.directory = directory; + this.harnessId = harnessId; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FileHarnessStore.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FileHarnessStore.java new file mode 100644 index 00000000..b5819f47 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/FileHarnessStore.java @@ -0,0 +1,286 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * File-backed Harness store with an authoritative JSON snapshot and an + * append-only journal. The journal lets the store recover a committed update + * if the process stops between journal append and snapshot replacement. + */ +public final class FileHarnessStore implements HarnessStore { + + private static final ConcurrentMap JVM_LOCKS = new ConcurrentHashMap(); + + private final Path directory; + private final Path stateFile; + private final Path journalFile; + private final Path lockFile; + private final String harnessId; + private final long journalCompactionBytes; + private final ReentrantLock jvmLock; + + public FileHarnessStore(FileHarnessConfig config) { + if (config == null || config.getDirectory() == null) { + throw new IllegalArgumentException("file harness directory is required"); + } + this.directory = config.getDirectory().toAbsolutePath().normalize(); + this.harnessId = config.getHarnessId() == null || config.getHarnessId().trim().isEmpty() + ? "default" : config.getHarnessId().trim(); + this.journalCompactionBytes = config.getJournalCompactionBytes(); + this.stateFile = directory.resolve("state.json"); + this.journalFile = directory.resolve("journal.jsonl"); + this.lockFile = directory.resolve(".lock"); + this.jvmLock = JVM_LOCKS.computeIfAbsent(directory, ignored -> new ReentrantLock()); + try { + Files.createDirectories(directory); + } catch (IOException error) { + throw new HarnessStoreException("cannot create Harness directory: " + directory, error); + } + } + + @Override + public HarnessState load() { + jvmLock.lock(); + try { + return withFileLock(new LockedOperation() { + @Override + public HarnessState run() throws IOException { + HarnessState recovered = readRecovered(); + if (!Files.exists(stateFile) || recovered.getVersion() > readSnapshotVersion()) { + writeSnapshot(recovered); + } + return recovered.copy(); + } + }); + } finally { + jvmLock.unlock(); + } + } + + @Override + public HarnessState update(final HarnessStateMutation mutation) { + if (mutation == null) { + throw new IllegalArgumentException("Harness mutation is required"); + } + jvmLock.lock(); + try { + return withFileLock(new LockedOperation() { + @Override + public HarnessState run() throws IOException { + HarnessState current = readRecovered(); + HarnessState next = mutation.apply(current.copy()); + if (next == null) { + throw new HarnessStoreException("Harness mutation returned null"); + } + next.ensureCollections(); + next.setHarnessId(harnessId); + next.setVersion(current.getVersion() + 1L); + next.setUpdatedAtEpochMs(System.currentTimeMillis()); + appendJournal(next); + writeSnapshot(next); + compactJournalIfNeeded(); + return next.copy(); + } + }); + } finally { + jvmLock.unlock(); + } + } + + private HarnessState readRecovered() throws IOException { + HarnessState snapshot; + HarnessStoreException snapshotFailure = null; + if (!Files.exists(stateFile)) { + snapshot = HarnessState.empty(harnessId); + } else { + try { + snapshot = readSnapshot(); + } catch (HarnessStoreException error) { + snapshotFailure = error; + // A corrupt snapshot may still be recoverable from the + // append-only journal. Do not expose this empty state unless + // a complete journal state is actually found below. + snapshot = HarnessState.empty(harnessId); + } + } + HarnessState latest = snapshot; + boolean recoveredJournalState = false; + if (Files.exists(journalFile)) { + List lines = Files.readAllLines(journalFile, StandardCharsets.UTF_8); + int lastNonBlank = -1; + for (int i = 0; i < lines.size(); i++) { + if (!lines.get(i).trim().isEmpty()) { + lastNonBlank = i; + } + } + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i); + if (line.trim().isEmpty()) { + continue; + } + try { + JSONObject envelope = JSON.parseObject(line); + if (envelope == null || envelope.get("state") == null) { + throw new IllegalArgumentException("journal entry has no state"); + } + long version = envelope.getLongValue("version"); + Object rawState = envelope.get("state"); + HarnessState candidate = rawState instanceof String + ? JSON.parseObject((String) rawState, HarnessState.class) + : JSON.parseObject(JSON.toJSONString(rawState), HarnessState.class); + if (candidate == null || candidate.getVersion() != version) { + throw new IllegalArgumentException("journal entry version does not match its state"); + } + recoveredJournalState = true; + if (version > latest.getVersion()) { + latest = candidate; + } + } catch (RuntimeException error) { + if (i == lastNonBlank) { + // Only the final non-blank line may be torn by a + // process crash. Earlier corruption is not safe to + // skip because it could hide a durable state change. + continue; + } + throw new HarnessStoreException("cannot recover Harness journal line " + (i + 1), error); + } + } + } + if (snapshotFailure != null && !recoveredJournalState) { + throw new HarnessStoreException("Harness snapshot is corrupt and no recoverable journal state exists", + snapshotFailure); + } + return validateHarness(latest); + } + + private HarnessState readSnapshot() throws IOException { + if (!Files.exists(stateFile)) { + return HarnessState.empty(harnessId); + } + try { + String json = new String(Files.readAllBytes(stateFile), StandardCharsets.UTF_8); + HarnessState state = JSON.parseObject(json, HarnessState.class); + if (state == null) { + throw new IllegalArgumentException("snapshot is empty"); + } + return validateHarness(state); + } catch (RuntimeException error) { + if (error instanceof HarnessStoreException) { + throw (HarnessStoreException) error; + } + throw new HarnessStoreException("cannot decode Harness snapshot: " + stateFile, error); + } + } + + private long readSnapshotVersion() throws IOException { + if (!Files.exists(stateFile)) { + return -1L; + } + try { + HarnessState state = JSON.parseObject(new String(Files.readAllBytes(stateFile), StandardCharsets.UTF_8), HarnessState.class); + return state == null ? -1L : state.getVersion(); + } catch (RuntimeException error) { + return -1L; + } + } + + private HarnessState validateHarness(HarnessState state) { + if (state == null) { + return HarnessState.empty(harnessId); + } + if (state.getHarnessId() == null || state.getHarnessId().trim().isEmpty()) { + state.setHarnessId(harnessId); + } else if (!harnessId.equals(state.getHarnessId())) { + throw new HarnessStoreException("Harness id mismatch: expected " + harnessId + + ", found " + state.getHarnessId()); + } + state.ensureCollections(); + return state; + } + + private void appendJournal(HarnessState state) throws IOException { + JSONObject envelope = new JSONObject(); + envelope.put("version", state.getVersion()); + envelope.put("state", JSON.parseObject(JSON.toJSONString(state))); + try (BufferedWriter writer = Files.newBufferedWriter(journalFile, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE)) { + writer.write(envelope.toJSONString()); + writer.newLine(); + } + } + + private void writeSnapshot(HarnessState state) throws IOException { + Path temporary = directory.resolve("state.json.tmp-" + Thread.currentThread().getId()); + Files.write(temporary, JSON.toJSONString(state).getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + try { + Files.move(temporary, stateFile, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } + + /** + * The journal protects the append-before-snapshot window. Once the + * snapshot replacement succeeds it is safe to trim old entries; keeping + * the journal bounded prevents heartbeat/tool-heavy Harnesses from + * turning every restart into a full-history replay. + */ + private void compactJournalIfNeeded() { + if (journalCompactionBytes <= 0L) { + return; + } + try { + if (!Files.exists(journalFile) || Files.size(journalFile) <= journalCompactionBytes) { + return; + } + Path temporary = directory.resolve("journal.jsonl.tmp-" + Thread.currentThread().getId()); + Files.write(temporary, new byte[0], StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + try { + Files.move(temporary, journalFile, StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, journalFile, StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(temporary); + } + } catch (IOException ignored) { + // The snapshot is already the committed state. Leaving the old + // journal in place is safe and the next update can retry trim. + } + } + + private T withFileLock(LockedOperation operation) { + try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.CREATE, StandardOpenOption.WRITE); + FileLock ignored = channel.lock()) { + return operation.run(); + } catch (HarnessStoreException error) { + throw error; + } catch (Exception error) { + throw new HarnessStoreException("file Harness store operation failed", error); + } + } + + private interface LockedOperation { + T run() throws Exception; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateRecord.java new file mode 100644 index 00000000..135f39d7 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateRecord.java @@ -0,0 +1,24 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class GateRecord { + + private String gateId; + private String taskId; + private String name; + private GateStatus status; + private String reason; + private long evaluatedAtEpochMs; + + public GateRecord copy() { + return HarnessJson.copy(this, GateRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateResult.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateResult.java new file mode 100644 index 00000000..b8dfa27e --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateResult.java @@ -0,0 +1,29 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class GateResult { + + private String name; + private GateStatus status; + private String reason; + + public static GateResult pass(String name) { + return GateResult.builder().name(name).status(GateStatus.PASS).reason("passed").build(); + } + + public static GateResult fail(String name, String reason) { + return GateResult.builder().name(name).status(GateStatus.FAIL).reason(reason).build(); + } + + public boolean isPassed() { + return GateStatus.PASS.equals(status); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateStatus.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateStatus.java new file mode 100644 index 00000000..f646f96b --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/GateStatus.java @@ -0,0 +1,6 @@ +package io.github.lnyocly.ai4j.harness; + +public enum GateStatus { + PASS, + FAIL +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessActor.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessActor.java new file mode 100644 index 00000000..d664c7d1 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessActor.java @@ -0,0 +1,46 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Identity used for attribution and authorization at the Harness boundary. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessActor { + + private String kind; + private String id; + private String displayName; + + public static HarnessActor agent(String id) { + return HarnessActor.builder().kind("agent").id(id).displayName(id).build(); + } + + public static HarnessActor human(String id) { + return HarnessActor.builder().kind("human").id(id).displayName(id).build(); + } + + public static HarnessActor system(String id) { + return HarnessActor.builder().kind("system").id(id).displayName(id).build(); + } + + public static HarnessActor worker(String id) { + return HarnessActor.builder().kind("worker").id(id).displayName(id).build(); + } + + public boolean isAgent() { + return "agent".equalsIgnoreCase(kind); + } + + public boolean isHuman() { + return "human".equalsIgnoreCase(kind); + } + + public boolean isSystem() { + return "system".equalsIgnoreCase(kind); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterDelivery.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterDelivery.java new file mode 100644 index 00000000..a1b93dae --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterDelivery.java @@ -0,0 +1,18 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Updated adapter state and replacement result for a delivered wait. */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class HarnessAdapterDelivery { + + private HarnessAdapterState state; + + private boolean replacedPendingResult; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterExecution.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterExecution.java new file mode 100644 index 00000000..07f943ed --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterExecution.java @@ -0,0 +1,30 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Result of one adapter-owned execution slice. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessAdapterExecution { + + private AgentExecutionStatus status; + private String outputText; + private String error; + private String waitId; + private String operationId; + private String checkpointSummary; + private HarnessAdapterState state; + private Object result; + + @Builder.Default + private Map checkpointState = new LinkedHashMap(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterState.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterState.java new file mode 100644 index 00000000..b0298606 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessAdapterState.java @@ -0,0 +1,34 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Durable state owned by a Harness execution adapter. + * + *

The Harness ledger stores this as an opaque, adapter-owned payload. The + * ledger can therefore coordinate any Agent runtime without pretending that + * every runtime has the same session format.

+ */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessAdapterState { + + private String adapterType; + + private String sessionId; + + @Builder.Default + private Map payload = new LinkedHashMap(); + + public HarnessAdapterState copy() { + return HarnessJson.copy(this, HarnessAdapterState.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessCommandGateway.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessCommandGateway.java new file mode 100644 index 00000000..d7884d94 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessCommandGateway.java @@ -0,0 +1,3409 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import io.github.lnyocly.ai4j.agent.session.AgentSessionSnapshot; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * The only command surface that mutates a Harness ledger. + * + *

The gateway is deliberately domain neutral. Applications decide what a + * task means, while this class owns durable task decomposition, dependency + * safety, provenance, leases, waits, checkpoints and completion gates.

+ */ +public final class HarnessCommandGateway implements AutoCloseable { + + private static final String IDEMPOTENCY_TASK = "task"; + private static final String IDEMPOTENCY_EXECUTION = "execution"; + + private final HarnessStore store; + private final HarnessContract contract; + private final HarnessActor defaultActor; + + public HarnessCommandGateway(HarnessStore store) { + this(store, HarnessContract.builder().build(), HarnessActor.agent("harness-agent")); + } + + public HarnessCommandGateway(HarnessStore store, + HarnessContract contract, + HarnessActor defaultActor) { + if (store == null) { + throw new IllegalArgumentException("Harness store is required"); + } + this.store = store; + this.contract = contract == null ? HarnessContract.builder().build() : contract; + this.defaultActor = normalizeActor(defaultActor, HarnessActor.agent("harness-agent")); + } + + public HarnessStore getStore() { + return store; + } + + public HarnessContract getContract() { + return contract; + } + + public HarnessActor getDefaultActor() { + return copyActor(defaultActor); + } + + public HarnessState getState() { + HarnessState state = store.load(); + if (state == null) { + return HarnessState.empty("default"); + } + state.ensureCollections(); + return state.copy(); + } + + public TaskRecord getTask(String taskId) { + TaskRecord task = getState().getTasks().get(taskId); + return task == null ? null : task.copy(); + } + + /** Returns a Task only when it belongs to the caller's opaque scope. */ + public TaskRecord getTaskInScope(String taskId, String scopeKey) { + TaskRecord task = getState().getTasks().get(taskId); + return task != null && visibleScope(task.getScopeKey(), scopeKey) + ? task.copy() : null; + } + + public List listTasks() { + return listTasks(null); + } + + public List listTasks(String scopeKey) { + List result = new ArrayList(); + for (TaskRecord task : getState().getTasks().values()) { + if (task != null && visibleScope(task.getScopeKey(), scopeKey)) { + result.add(task.copy()); + } + } + return result; + } + + public List listRunnableTasks() { + return listRunnableTasks(null); + } + + public List listRunnableTasks(String scopeKey) { + HarnessState state = getState(); + List result = new ArrayList(); + for (TaskRecord task : state.getTasks().values()) { + if (task == null || !isRunnableStatus(task.getStatus()) + || !visibleScope(task.getScopeKey(), scopeKey) + || !dependenciesSatisfied(state, task.getTaskId()) + || hasOutstandingExecution(state, task.getTaskId())) { + continue; + } + result.add(task.copy()); + } + return result; + } + + public ExecutionRecord getExecution(String executionId) { + ExecutionRecord execution = getState().getExecutions().get(executionId); + return execution == null ? null : execution.copy(); + } + + public List listExecutions() { + List result = new ArrayList(); + for (ExecutionRecord execution : getState().getExecutions().values()) { + if (execution != null) { + result.add(execution.copy()); + } + } + return result; + } + + public WaitRecord getWait(String waitId) { + WaitRecord wait = getState().getWaits().get(waitId); + return wait == null ? null : wait.copy(); + } + + /** + * Returns every unresolved wait belonging to one Execution in creation + * order. Execution.waitId is only a representative id; callers that need + * to deliver multiple parallel operations must use this view instead of + * assuming that an Execution has at most one wait. + */ + public List listOpenWaits(String executionId) { + String id = trimToNull(executionId); + if (id == null) { + return Collections.emptyList(); + } + List result = new ArrayList(); + for (WaitRecord wait : openWaits(getState(), id)) { + result.add(wait.copy()); + } + return result; + } + + public AgentSessionSnapshot getSessionSnapshot(String sessionId) { + String id = trimToNull(sessionId); + if (id == null) { + return null; + } + AgentSessionSnapshot snapshot = getState().getSessions().get(id); + return snapshot == null ? null : HarnessJson.copy(snapshot, AgentSessionSnapshot.class); + } + + public CheckpointRecord getCheckpoint(String checkpointId) { + String id = trimToNull(checkpointId); + if (id == null) { + return null; + } + CheckpointRecord checkpoint = getState().getCheckpoints().get(id); + return checkpoint == null ? null : checkpoint.copy(); + } + + public WakeupRecord getLatestWakeup(String executionId) { + String id = trimToNull(executionId); + if (id == null) { + return null; + } + WakeupRecord latest = null; + for (WakeupRecord wakeup : getState().getWakeups().values()) { + if (wakeup == null || !id.equals(wakeup.getExecutionId())) { + continue; + } + if (latest == null || wakeup.getDeliveredAtEpochMs() > latest.getDeliveredAtEpochMs()) { + latest = wakeup; + } + } + return latest == null ? null : latest.copy(); + } + + /** Records a durable observation without exposing the store to callers. */ + public HarnessEventRecord recordEvent(String type, + String entityId, + Map payload, + HarnessActor actor) { + final String eventType = requireText(type, "event type"); + final String eventEntityId = trimToNull(entityId); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public HarnessEventRecord apply(HarnessState state) { + HarnessEventRecord event = HarnessEventRecord.builder() + .type(eventType) + .entityId(eventEntityId) + .actorId(actorKey(effectiveActor)) + .recordedAtEpochMs(now()) + .payload(copyMap(payload)) + .build(); + List events = state.getEvents(); + long sequence = events == null || events.isEmpty() + ? 1L : events.get(events.size() - 1).getSequence() + 1L; + event.setSequence(sequence); + if (events == null) { + events = new ArrayList(); + state.setEvents(events); + } + events.add(event); + return event.copy(); + } + }); + } + + public List listOpenWaits() { + return listOpenWaitsInScope(null); + } + + public List listOpenWaitsInScope(String scopeKey) { + HarnessState state = getState(); + List result = new ArrayList(); + for (WaitRecord wait : state.getWaits().values()) { + ExecutionRecord execution = wait == null ? null : state.getExecutions().get(wait.getExecutionId()); + if (wait != null && WaitStatus.OPEN.equals(wait.getStatus()) + && execution != null && visibleScope(execution.getScopeKey(), scopeKey)) { + result.add(wait.copy()); + } + } + return result; + } + + public List listFactsInScope(String scopeKey) { + List result = new ArrayList(); + for (FactRecord fact : getState().getFacts().values()) { + if (fact != null && visibleScope(fact.getScopeKey(), scopeKey)) { + result.add(fact.copy()); + } + } + return result; + } + + public List listDecisionsInScope(String scopeKey) { + List result = new ArrayList(); + for (DecisionRecord decision : getState().getDecisions().values()) { + if (decision != null && visibleScope(decision.getScopeKey(), scopeKey)) { + result.add(decision.copy()); + } + } + return result; + } + + public List listEvidenceInScope(String scopeKey) { + List result = new ArrayList(); + for (EvidenceRecord item : getState().getEvidence().values()) { + if (item != null && visibleScope(item.getScopeKey(), scopeKey)) { + result.add(item.copy()); + } + } + return result; + } + + public List listRelations() { + return listRelationsInScope(null); + } + + public List listRelationsInScope(String scopeKey) { + List result = new ArrayList(); + for (RelationRecord relation : getState().getRelations().values()) { + if (relation != null && visibleRelationScope(relation, scopeKey)) { + result.add(relation.copy()); + } + } + return result; + } + + public RelationRecord getRelation(String relationId) { + RelationRecord relation = getState().getRelations().get(relationId); + return relation == null ? null : relation.copy(); + } + + /** Returns a Relation only when it belongs to the caller's opaque scope. */ + public RelationRecord getRelationInScope(String relationId, String scopeKey) { + RelationRecord relation = getRelation(relationId); + return relation != null && visibleRelationScope(relation, scopeKey) + ? relation : null; + } + + public ToolInvocationRecord getToolInvocation(String invocationId) { + ToolInvocationRecord invocation = getState().getToolInvocations().get(invocationId); + return invocation == null ? null : invocation.copy(); + } + + public ToolInvocationRecord getToolInvocationInScope(String invocationId, String scopeKey) { + ToolInvocationRecord invocation = getToolInvocation(invocationId); + return invocation != null && visibleScope(invocation.getScopeKey(), scopeKey) + ? invocation : null; + } + + public List listToolInvocationsInScope(String scopeKey) { + List result = new ArrayList(); + for (ToolInvocationRecord invocation : getState().getToolInvocations().values()) { + if (invocation != null && visibleScope(invocation.getScopeKey(), scopeKey)) { + result.add(invocation.copy()); + } + } + return result; + } + + /** + * Finds the sole approved invocation waiting for a provider retry whose + * call id may have changed across Agent sessions. Ambiguous matches are + * deliberately rejected so two identical parallel calls are never + * rebound to the wrong side effect. + */ + ToolInvocationRecord findApprovedWaitingToolInvocation(String executionId, + String scopeKey, + String toolName, + String callId, + String arguments) { + HarnessState state = getState(); + ToolInvocationRecord match = null; + for (ToolInvocationRecord invocation : state.getToolInvocations().values()) { + if (invocation == null || !ToolInvocationStatus.WAITING.equals(invocation.getStatus()) + || !safeEquals(executionId, invocation.getExecutionId()) + || !visibleScope(invocation.getScopeKey(), scopeKey) + || !safeEquals(toolName, invocation.getToolName()) + || !equivalentNullableArguments(invocation.getArguments(), arguments) + || !approvedInvocationRetry(state, invocation, toolName, callId, arguments)) { + continue; + } + if (match != null) { + return null; + } + match = invocation; + } + return match == null ? null : match.copy(); + } + + public ToolInvocationRecord beginToolInvocation(HarnessToolInvocationSpec spec) { + return beginToolInvocation(spec, defaultActor); + } + + /** Records STARTED before a business tool is allowed to perform a side effect. */ + public ToolInvocationRecord beginToolInvocation(HarnessToolInvocationSpec spec, + HarnessActor actor) { + return reserveToolInvocation(spec, actor).getInvocation(); + } + + /** + * Atomically records or observes a STARTED invocation. + * + *

The boolean is part of the command result because a read followed by + * {@link #beginToolInvocation} cannot distinguish the creator from a + * concurrent observer. Callers that may perform an external side effect + * must execute only when {@link ToolInvocationReservation#isCreated()} is + * {@code true}.

+ */ + public ToolInvocationReservation reserveToolInvocation(HarnessToolInvocationSpec spec) { + return reserveToolInvocation(spec, defaultActor); + } + + /** Atomically records or observes a STARTED invocation with actor attribution. */ + public ToolInvocationReservation reserveToolInvocation(HarnessToolInvocationSpec spec, + HarnessActor actor) { + if (spec == null) { + throw new HarnessValidationException("tool invocation specification is required"); + } + final String invocationId = requireText(spec.getInvocationId(), "tool invocation id"); + final String executionId = requireText(spec.getExecutionId(), "execution id"); + final String toolName = requireText(spec.getToolName(), "tool name"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ToolInvocationReservation apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionId); + String invocationScope = normalizeScope(spec.getScopeKey()); + assertCompatibleScope(invocationScope, execution.getScopeKey(), + "tool invocation and execution scopes must match"); + if (invocationScope == null) { + invocationScope = execution.getScopeKey(); + } + if (spec.getTaskId() != null && !safeEquals(spec.getTaskId(), execution.getTaskId())) { + throw new HarnessConflictException("tool invocation task does not belong to execution: " + + invocationId); + } + if (spec.getSessionId() != null && !safeEquals(spec.getSessionId(), execution.getSessionId())) { + throw new HarnessConflictException("tool invocation session does not belong to execution: " + + invocationId); + } + ToolInvocationRecord existing = state.getToolInvocations().get(invocationId); + if (existing != null) { + if (ToolInvocationStatus.WAITING.equals(existing.getStatus()) + && approvedInvocationRetry(state, existing, spec)) { + assertSameToolInvocationForApprovedRetry(existing, spec, invocationScope); + existing.setStatus(ToolInvocationStatus.STARTED); + existing.setOperationId(null); + existing.setWaitId(null); + existing.setOutput(null); + existing.setError(null); + existing.setUpdatedAtEpochMs(now()); + existing.setVersion(existing.getVersion() + 1L); + addEvent(state, "tool.invocation_restarted_after_approval", invocationId, + effectiveActor, mapOf("executionId", executionId, + "toolName", toolName, "callId", spec.getCallId())); + return ToolInvocationReservation.builder() + .invocation(existing.copy()) + .created(true) + .build(); + } + assertSameToolInvocation(existing, spec, invocationScope); + return ToolInvocationReservation.builder() + .invocation(existing.copy()) + .created(false) + .build(); + } + long currentTime = now(); + ToolInvocationRecord invocation = ToolInvocationRecord.builder() + .invocationId(invocationId) + .executionId(executionId) + .taskId(spec.getTaskId() == null ? execution.getTaskId() : spec.getTaskId()) + .sessionId(spec.getSessionId() == null ? execution.getSessionId() : spec.getSessionId()) + .scopeKey(invocationScope) + .toolName(toolName) + .callId(spec.getCallId()) + .arguments(spec.getArguments()) + .status(ToolInvocationStatus.STARTED) + .createdBy(actorKey(effectiveActor)) + .createdAtEpochMs(currentTime) + .updatedAtEpochMs(currentTime) + .version(1L) + .build(); + state.getToolInvocations().put(invocationId, invocation); + addEvent(state, "tool.invocation_started", invocationId, effectiveActor, + mapOf("executionId", executionId, "taskId", invocation.getTaskId(), + "toolName", toolName, "callId", spec.getCallId())); + return ToolInvocationReservation.builder() + .invocation(invocation.copy()) + .created(true) + .build(); + } + }); + } + + public ToolInvocationRecord markToolInvocationWaiting(String invocationId, + String operationId, + String waitId) { + return markToolInvocationWaiting(invocationId, operationId, waitId, defaultActor); + } + + public ToolInvocationRecord markToolInvocationWaiting(String invocationId, + String operationId, + String waitId, + HarnessActor actor) { + final String id = requireText(invocationId, "tool invocation id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ToolInvocationRecord apply(HarnessState state) { + ToolInvocationRecord invocation = state.getToolInvocations().get(id); + if (invocation == null) { + throw new HarnessValidationException("tool invocation not found: " + id); + } + if (ToolInvocationStatus.CANCELLED.equals(invocation.getStatus()) + || ToolInvocationStatus.SUCCEEDED.equals(invocation.getStatus()) + || ToolInvocationStatus.FAILED.equals(invocation.getStatus()) + || ToolInvocationStatus.UNKNOWN.equals(invocation.getStatus())) { + return invocation.copy(); + } + if (ToolInvocationStatus.WAITING.equals(invocation.getStatus())) { + if (operationId != null && invocation.getOperationId() != null + && !safeEquals(operationId, invocation.getOperationId())) { + throw new HarnessConflictException("tool invocation operation id changed: " + id); + } + if (waitId != null && invocation.getWaitId() != null + && !safeEquals(waitId, invocation.getWaitId())) { + throw new HarnessConflictException("tool invocation wait id changed: " + id); + } + return invocation.copy(); + } + if (!ToolInvocationStatus.STARTED.equals(invocation.getStatus())) { + throw new HarnessConflictException("tool invocation cannot wait from status " + + invocation.getStatus() + ": " + id); + } + invocation.setStatus(ToolInvocationStatus.WAITING); + invocation.setOperationId(operationId); + invocation.setWaitId(waitId); + invocation.setUpdatedAtEpochMs(now()); + invocation.setVersion(invocation.getVersion() + 1L); + addEvent(state, "tool.invocation_waiting", id, effectiveActor, + mapOf("operationId", operationId, "waitId", waitId)); + return invocation.copy(); + } + }); + } + + public ToolInvocationRecord completeToolInvocation(String invocationId, + ToolInvocationStatus status, + String operationId, + String waitId, + String output, + String error) { + return completeToolInvocation(invocationId, status, operationId, waitId, + output, error, defaultActor); + } + + public ToolInvocationRecord completeToolInvocation(String invocationId, + ToolInvocationStatus status, + String operationId, + String waitId, + String output, + String error, + HarnessActor actor) { + final String id = requireText(invocationId, "tool invocation id"); + if (status == null || ToolInvocationStatus.STARTED.equals(status) + || ToolInvocationStatus.WAITING.equals(status)) { + throw new HarnessValidationException("tool invocation completion must be terminal"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ToolInvocationRecord apply(HarnessState state) { + ToolInvocationRecord invocation = state.getToolInvocations().get(id); + if (invocation == null) { + throw new HarnessValidationException("tool invocation not found: " + id); + } + if (ToolInvocationStatus.CANCELLED.equals(invocation.getStatus())) { + recordQuarantinedCompletion(state, invocation, status, operationId, waitId, + output, error, effectiveActor); + return invocation.copy(); + } + if (ToolInvocationStatus.SUCCEEDED.equals(invocation.getStatus()) + || ToolInvocationStatus.FAILED.equals(invocation.getStatus()) + || ToolInvocationStatus.UNKNOWN.equals(invocation.getStatus())) { + return invocation.copy(); + } + invocation.setStatus(status); + if (operationId != null) invocation.setOperationId(operationId); + if (waitId != null) invocation.setWaitId(waitId); + invocation.setOutput(output); + invocation.setError(error); + invocation.setUpdatedAtEpochMs(now()); + invocation.setVersion(invocation.getVersion() + 1L); + addEvent(state, "tool.invocation_" + status.name().toLowerCase(), id, + effectiveActor, mapOf("operationId", operationId, "waitId", waitId, + "error", error)); + return invocation.copy(); + } + }); + } + + public ToolInvocationRecord reconcileToolInvocation(String invocationId, + ToolInvocationStatus resolution, + String output, + String error, + HarnessActor actor) { + final String id = requireText(invocationId, "tool invocation id"); + if (resolution == null || ToolInvocationStatus.STARTED.equals(resolution) + || ToolInvocationStatus.WAITING.equals(resolution)) { + throw new HarnessValidationException("tool invocation reconciliation must be terminal"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ToolInvocationRecord apply(HarnessState state) { + ToolInvocationRecord invocation = state.getToolInvocations().get(id); + if (invocation == null) { + throw new HarnessValidationException("tool invocation not found: " + id); + } + if (!contract.mayReconcileToolInvocation(effectiveActor, invocation, resolution)) { + throw new HarnessValidationException( + "actor is not allowed to reconcile this tool invocation"); + } + if (ToolInvocationStatus.CANCELLED.equals(invocation.getStatus()) + || ToolInvocationStatus.SUCCEEDED.equals(invocation.getStatus()) + || ToolInvocationStatus.FAILED.equals(invocation.getStatus())) { + throw new HarnessConflictException("tool invocation is already terminal: " + id); + } + invocation.setStatus(resolution); + invocation.setOutput(output); + invocation.setError(error); + invocation.setUpdatedAtEpochMs(now()); + invocation.setVersion(invocation.getVersion() + 1L); + addEvent(state, "tool.invocation_reconciled", id, effectiveActor, + mapOf("resolution", resolution, "error", error)); + return invocation.copy(); + } + }); + } + + public TaskRecord createTask(HarnessTaskSpec spec) { + return createTask(spec, defaultActor); + } + + public TaskRecord createTask(HarnessTaskSpec spec, HarnessActor actor) { + if (spec == null) { + throw new HarnessValidationException("task specification is required"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + final String taskId = valueOrGenerated(spec.getTaskId(), "task_"); + return write(new StateCommand() { + @Override + public TaskRecord apply(HarnessState state) { + String taskScope = taskSpecScope(state, spec); + HarnessTaskSpec effectiveSpec = withScope(spec, taskScope); + String existingId = idempotentId(state, IDEMPOTENCY_TASK, taskScope, + spec.getIdempotencyKey()); + if (existingId != null) { + TaskRecord existing = state.getTasks().get(existingId); + if (existing != null) { + return existing.copy(); + } + } + TaskRecord task = createTaskInternal(state, effectiveSpec, taskId, effectiveActor); + rememberIdempotency(state, IDEMPOTENCY_TASK, taskScope, + spec.getIdempotencyKey(), task.getTaskId()); + return task.copy(); + } + }); + } + + /** + * Creates a Task and attaches an unbound Execution in one durable state + * mutation. This is used when an Agent discovers its first unit of work at + * runtime; a failed attachment must not leave an orphan Task behind. + */ + public TaskRecord createTaskAndAttachExecution(HarnessTaskSpec spec, + String executionId, + HarnessActor actor) { + if (spec == null) { + throw new HarnessValidationException("task specification is required"); + } + final String executionKey = requireText(executionId, "execution id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + final String taskId = valueOrGenerated(spec.getTaskId(), "task_"); + return write(new StateCommand() { + @Override + public TaskRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + String taskScope = taskSpecScope(state, spec); + assertCompatibleScope(taskScope, execution.getScopeKey(), + "task and execution scopes must match"); + if (taskScope == null) { + taskScope = normalizeScope(execution.getScopeKey()); + } + HarnessTaskSpec effectiveSpec = withScope(spec, taskScope); + String existingId = idempotentId(state, IDEMPOTENCY_TASK, taskScope, + spec.getIdempotencyKey()); + TaskRecord task = existingId == null ? null : state.getTasks().get(existingId); + if (task == null) { + task = createTaskInternal(state, effectiveSpec, taskId, effectiveActor); + rememberIdempotency(state, IDEMPOTENCY_TASK, taskScope, + spec.getIdempotencyKey(), task.getTaskId()); + } + attachExecutionToTaskInternal(state, executionKey, task.getTaskId(), effectiveActor); + return task.copy(); + } + }); + } + + public List splitTask(String parentTaskId, List children) { + return splitTask(parentTaskId, children, defaultActor); + } + + public List splitTask(String parentTaskId, + List children, + HarnessActor actor) { + final String parentId = requireText(parentTaskId, "parent task id"); + final List requested = children == null + ? Collections.emptyList() : new ArrayList(children); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand>() { + @Override + public List apply(HarnessState state) { + TaskRecord parent = requireTask(state, parentId); + if (TaskStatus.DONE.equals(parent.getStatus()) || TaskStatus.CANCELLED.equals(parent.getStatus())) { + throw new HarnessValidationException("cannot split a terminal task: " + parentId); + } + List result = new ArrayList(); + for (HarnessTaskSpec childSpec : requested) { + if (childSpec == null) { + continue; + } + assertCompatibleScope(childSpec.getScopeKey(), parent.getScopeKey(), + "child task and parent task scopes must match"); + HarnessTaskSpec effectiveSpec = childSpec.toBuilder() + .parentTaskId(parentId) + .scopeKey(childSpec.getScopeKey() == null + ? parent.getScopeKey() : childSpec.getScopeKey()) + .build(); + String childId = valueOrGenerated(effectiveSpec.getTaskId(), "task_"); + String childScope = normalizeScope(effectiveSpec.getScopeKey()); + String existingId = idempotentId(state, IDEMPOTENCY_TASK, childScope, + effectiveSpec.getIdempotencyKey()); + TaskRecord child = existingId == null ? null : state.getTasks().get(existingId); + if (child == null) { + child = createTaskInternal(state, effectiveSpec, childId, effectiveActor); + rememberIdempotency(state, IDEMPOTENCY_TASK, childScope, + effectiveSpec.getIdempotencyKey(), child.getTaskId()); + } + result.add(child.copy()); + } + return result; + } + }); + } + + public TaskRecord updateTask(String taskId, + String title, + String goal, + String plan, + Map metadata) { + return updateTask(taskId, title, goal, plan, metadata, defaultActor); + } + + public TaskRecord updateTask(String taskId, + String title, + String goal, + String plan, + Map metadata, + HarnessActor actor) { + final String id = requireText(taskId, "task id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public TaskRecord apply(HarnessState state) { + TaskRecord task = requireTask(state, id); + if (title != null && !title.trim().isEmpty()) task.setTitle(title.trim()); + if (goal != null) task.setGoal(goal); + if (plan != null) task.setPlan(plan); + if (metadata != null) task.setMetadata(new LinkedHashMap(metadata)); + task.setUpdatedAtEpochMs(now()); + task.setVersion(task.getVersion() + 1L); + addEvent(state, "task.updated", id, effectiveActor, mapOf( + "title", task.getTitle(), "goal", task.getGoal(), "plan", task.getPlan())); + return task.copy(); + } + }); + } + + public TaskRecord transitionTask(String taskId, TaskStatus target, String reason) { + return transitionTask(taskId, target, reason, defaultActor); + } + + public TaskRecord transitionTask(String taskId, + TaskStatus target, + String reason, + HarnessActor actor) { + final String id = requireText(taskId, "task id"); + if (target == null) { + throw new HarnessValidationException("task target status is required"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + final String transitionReason = reason; + return write(new StateCommand() { + @Override + public TaskRecord apply(HarnessState state) { + TaskRecord task = requireTask(state, id); + requireTaskTransition(task.getStatus(), target); + if (TaskStatus.DONE.equals(target)) { + throw new HarnessValidationException("use completeTask for DONE transitions"); + } + if (TaskStatus.IN_REVIEW.equals(target) && effectiveActor.isAgent()) { + throw new HarnessValidationException("an Agent must submit a task for review; it cannot enter review directly"); + } + if (TaskStatus.ACTIVE.equals(target) && !dependenciesSatisfied(state, id)) { + throw new HarnessConflictException("task dependencies are not complete: " + id); + } + TaskStatus previousStatus = task.getStatus(); + task.setStatus(target); + task.setBlockedReason(TaskStatus.BLOCKED.equals(target) ? transitionReason : null); + task.setUpdatedAtEpochMs(now()); + task.setVersion(task.getVersion() + 1L); + if (TaskStatus.CANCELLED.equals(target)) { + cancelTaskActivity(state, id, transitionReason, effectiveActor); + } + addEvent(state, "task.transitioned", id, effectiveActor, mapOf( + "from", previousStatus, "to", target, "reason", transitionReason)); + return task.copy(); + } + }); + } + + public RelationRecord addRelation(HarnessRelationSpec spec) { + return addRelation(spec, defaultActor); + } + + public RelationRecord addRelation(HarnessRelationSpec spec, HarnessActor actor) { + if (spec == null || spec.getType() == null) { + throw new HarnessValidationException("relation type is required"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public RelationRecord apply(HarnessState state) { + validateRelationSpec(state, spec); + RelationRecord existing = findRelation(state, spec); + if (existing != null) { + return existing.copy(); + } + if (isGraphRelation(spec.getType()) && wouldCreateCycle(state, + spec.getType(), spec.getFromId(), spec.getToId())) { + throw new HarnessConflictException("relation would create a cycle: " + + spec.getFromId() + " -> " + spec.getToId()); + } + String relationScope = resolveRelationScope(state, spec); + String id = valueOrGenerated(null, "rel_"); + RelationRecord relation = RelationRecord.builder() + .relationId(id) + .scopeKey(relationScope) + .type(spec.getType()) + .fromKind(spec.getFromKind()) + .fromId(spec.getFromId()) + .toKind(spec.getToKind()) + .toId(spec.getToId()) + .createdBy(actorKey(effectiveActor)) + .createdAtEpochMs(now()) + .metadata(copyMap(spec.getMetadata())) + .build(); + state.getRelations().put(id, relation); + addEvent(state, "relation.created", id, effectiveActor, mapOf( + "type", relation.getType(), "from", relation.getFromId(), "to", relation.getToId())); + return relation.copy(); + } + }); + } + + public RelationRecord addDependency(String taskId, String dependencyTaskId) { + return addDependency(taskId, dependencyTaskId, defaultActor); + } + + public RelationRecord addDependency(String taskId, + String dependencyTaskId, + HarnessActor actor) { + return addRelation(HarnessRelationSpec.builder() + .type(RelationType.DEPENDS_ON) + .fromKind(EntityKind.TASK) + .fromId(taskId) + .toKind(EntityKind.TASK) + .toId(dependencyTaskId) + .build(), actor); + } + + public FactRecord recordFact(HarnessFactSpec spec) { + return recordFact(spec, defaultActor); + } + + public FactRecord recordFact(HarnessFactSpec spec, HarnessActor actor) { + if (spec == null) { + throw new HarnessValidationException("fact specification is required"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + if (effectiveActor.isAgent() && !contract.acceptsAgentFact(spec)) { + throw new HarnessValidationException("the Harness contract rejected this Agent fact"); + } + final String factId = valueOrGenerated(spec.getFactId(), "fact_"); + return write(new StateCommand() { + @Override + public FactRecord apply(HarnessState state) { + if (state.getFacts().containsKey(factId)) { + throw new HarnessConflictException("fact already exists: " + factId); + } + String factTaskId = trimToNull(spec.getTaskId()); + String factScope = relatedScope(state, spec.getScopeKey(), + factTaskId, EntityKind.TASK, + "fact and task scopes must match"); + factScope = resolveEvidenceReferenceScope(state, spec.getEvidenceIds(), + factScope, factTaskId != null, + "fact and evidence scopes must match"); + FactRecord fact = FactRecord.builder() + .factId(factId) + .scopeKey(factScope) + .taskId(factTaskId) + .statement(requireText(spec.getStatement(), "fact statement")) + .source(spec.getSource()) + .confidence(spec.getConfidence()) + .valid(true) + .createdAtEpochMs(now()) + .provenance(provenance(effectiveActor, null, null, "fact", factId)) + .evidenceIds(copyList(spec.getEvidenceIds())) + .metadata(copyMap(spec.getMetadata())) + .build(); + state.getFacts().put(factId, fact); + addEvent(state, "fact.recorded", factId, effectiveActor, mapOf( + "taskId", fact.getTaskId(), "statement", fact.getStatement())); + return fact.copy(); + } + }); + } + + public FactRecord invalidateFact(String factId, String reason) { + return invalidateFact(factId, reason, defaultActor); + } + + public FactRecord invalidateFact(String factId, String reason, HarnessActor actor) { + final String id = requireText(factId, "fact id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public FactRecord apply(HarnessState state) { + FactRecord fact = state.getFacts().get(id); + if (fact == null) throw new HarnessValidationException("fact not found: " + id); + fact.setValid(false); + fact.setInvalidatedBy(actorKey(effectiveActor) + (reason == null ? "" : ": " + reason)); + fact.setInvalidatedAtEpochMs(now()); + addEvent(state, "fact.invalidated", id, effectiveActor, mapOf("reason", reason)); + return fact.copy(); + } + }); + } + + public DecisionRecord proposeDecision(HarnessDecisionSpec spec) { + return proposeDecision(spec, defaultActor); + } + + public DecisionRecord proposeDecision(HarnessDecisionSpec spec, HarnessActor actor) { + if (spec == null) throw new HarnessValidationException("decision specification is required"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + final String decisionId = valueOrGenerated(spec.getDecisionId(), "decision_"); + return write(new StateCommand() { + @Override + public DecisionRecord apply(HarnessState state) { + if (state.getDecisions().containsKey(decisionId)) { + throw new HarnessConflictException("decision already exists: " + decisionId); + } + String decisionTaskId = trimToNull(spec.getTaskId()); + String decisionScope = relatedScope(state, spec.getScopeKey(), + decisionTaskId, EntityKind.TASK, + "decision and task scopes must match"); + decisionScope = resolveFactReferenceScope(state, spec.getFactIds(), + decisionScope, decisionTaskId != null, + "decision and fact scopes must match"); + decisionScope = resolveEvidenceReferenceScope(state, spec.getEvidenceIds(), + decisionScope, decisionTaskId != null, + "decision and evidence scopes must match"); + DecisionRecord decision = DecisionRecord.builder() + .decisionId(decisionId) + .scopeKey(decisionScope) + .taskId(decisionTaskId) + .question(requireText(spec.getQuestion(), "decision question")) + .chosenOption(spec.getChosenOption()) + .rationale(spec.getRationale()) + .status(DecisionStatus.PROPOSED) + .proposer(copyActor(effectiveActor)) + .createdAtEpochMs(now()) + .factIds(copyList(spec.getFactIds())) + .evidenceIds(copyList(spec.getEvidenceIds())) + .build(); + state.getDecisions().put(decisionId, decision); + addEvent(state, "decision.proposed", decisionId, effectiveActor, mapOf( + "taskId", decision.getTaskId(), "question", decision.getQuestion())); + return decision.copy(); + } + }); + } + + public DecisionRecord resolveDecision(String decisionId, + DecisionStatus status, + String rationale, + HarnessActor actor) { + final String id = requireText(decisionId, "decision id"); + if (status != DecisionStatus.ACCEPTED && status != DecisionStatus.REJECTED) { + throw new HarnessValidationException("decision resolution must be ACCEPTED or REJECTED"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + if (effectiveActor.isAgent()) { + throw new HarnessValidationException("an Agent may propose a decision but cannot resolve it"); + } + return write(new StateCommand() { + @Override + public DecisionRecord apply(HarnessState state) { + DecisionRecord decision = state.getDecisions().get(id); + if (decision == null) throw new HarnessValidationException("decision not found: " + id); + decision.setStatus(status); + decision.setRationale(rationale == null ? decision.getRationale() : rationale); + decision.setArbiter(copyActor(effectiveActor)); + decision.setResolvedAtEpochMs(now()); + addEvent(state, "decision.resolved", id, effectiveActor, mapOf("status", status)); + return decision.copy(); + } + }); + } + + public EvidenceRecord recordEvidence(HarnessEvidenceSpec spec) { + return recordEvidence(spec, defaultActor); + } + + public EvidenceRecord recordEvidence(HarnessEvidenceSpec spec, HarnessActor actor) { + if (spec == null) throw new HarnessValidationException("evidence specification is required"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + final String evidenceId = valueOrGenerated(spec.getEvidenceId(), "evidence_"); + return write(new StateCommand() { + @Override + public EvidenceRecord apply(HarnessState state) { + if (state.getEvidence().containsKey(evidenceId)) { + throw new HarnessConflictException("evidence already exists: " + evidenceId); + } + String evidenceTaskId = trimToNull(spec.getTaskId()); + String evidenceExecutionId = trimToNull(spec.getExecutionId()); + String evidenceScope = relatedScope(state, spec.getScopeKey(), + evidenceTaskId, EntityKind.TASK, + "evidence and task scopes must match"); + if (evidenceExecutionId != null) { + ExecutionRecord execution = state.getExecutions().get(evidenceExecutionId); + if (execution == null) { + throw new HarnessValidationException("execution not found: " + evidenceExecutionId); + } + String executionTaskId = trimToNull(execution.getTaskId()); + if (evidenceTaskId != null && !evidenceTaskId.equals(executionTaskId)) { + throw new HarnessConflictException("evidence task and execution tasks must match"); + } + if (evidenceTaskId == null && executionTaskId != null) { + evidenceTaskId = executionTaskId; + } + if (evidenceTaskId != null) { + requireTask(state, evidenceTaskId); + assertSameScope(evidenceScope, execution.getScopeKey(), + "evidence and execution scopes must match"); + } else { + assertCompatibleScope(evidenceScope, execution.getScopeKey(), + "evidence and execution scopes must match"); + if (evidenceScope == null) { + evidenceScope = normalizeScope(execution.getScopeKey()); + } + } + } + EvidenceRecord evidence = EvidenceRecord.builder() + .evidenceId(evidenceId) + .scopeKey(evidenceScope) + .taskId(evidenceTaskId) + .executionId(evidenceExecutionId) + .kind(spec.getKind()) + .location(spec.getLocation()) + .summary(spec.getSummary()) + .contentRef(spec.getContentRef()) + .createdAtEpochMs(now()) + .provenance(provenance(effectiveActor, evidenceExecutionId, null, "evidence", evidenceId)) + .build(); + state.getEvidence().put(evidenceId, evidence); + addEvent(state, "evidence.recorded", evidenceId, effectiveActor, mapOf( + "taskId", evidence.getTaskId(), "executionId", evidence.getExecutionId(), "kind", evidence.getKind())); + return evidence.copy(); + } + }); + } + + public SubmissionRecord submitTask(String taskId, + String executionId, + HarnessSubmissionSpec spec) { + return submitTask(taskId, executionId, spec, defaultActor); + } + + public SubmissionRecord submitTask(String taskId, + String executionId, + HarnessSubmissionSpec spec, + HarnessActor actor) { + final String id = requireText(taskId, "task id"); + final HarnessSubmissionSpec requested = spec == null ? HarnessSubmissionSpec.builder().build() : spec; + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public SubmissionRecord apply(HarnessState state) { + TaskRecord task = requireTask(state, id); + if (TaskStatus.DONE.equals(task.getStatus()) || TaskStatus.CANCELLED.equals(task.getStatus())) { + throw new HarnessValidationException("cannot submit a terminal task: " + id); + } + String executionKey = trimToNull(executionId); + if (executionKey != null) { + ExecutionRecord execution = state.getExecutions().get(executionKey); + if (execution == null) { + throw new HarnessValidationException("execution not found: " + executionKey); + } + if (!id.equals(execution.getTaskId())) { + throw new HarnessValidationException("execution does not belong to task: " + id); + } + assertCurrentTaskExecution(task, executionKey); + assertSameScope(task.getScopeKey(), execution.getScopeKey(), + "submission and execution scopes must match"); + } + resolveEvidenceReferenceScope(state, requested.getEvidenceIds(), + task.getScopeKey(), true, + "submission and evidence scopes must match"); + String submissionId = valueOrGenerated(null, "submission_"); + SubmissionRecord submission = SubmissionRecord.builder() + .submissionId(submissionId) + .taskId(id) + .executionId(executionKey) + .submitter(copyActor(effectiveActor)) + .completionClaim(requested.getCompletionClaim()) + .verificationNotes(requested.getVerificationNotes()) + .deliverables(copyList(requested.getDeliverables())) + .evidenceIds(copyList(requested.getEvidenceIds())) + .knownGaps(copyList(requested.getKnownGaps())) + .residualRisks(copyList(requested.getResidualRisks())) + .createdAtEpochMs(now()) + .build(); + state.getSubmissions().put(submissionId, submission); + task.setSubmissionId(submissionId); + task.setStatus(TaskStatus.IN_REVIEW); + task.setUpdatedAtEpochMs(now()); + task.setVersion(task.getVersion() + 1L); + addEvent(state, "submission.created", submissionId, effectiveActor, mapOf("taskId", id)); + return submission.copy(); + } + }); + } + + public ReviewRecord reviewSubmission(String submissionId, + ReviewVerdict verdict, + String findings, + String rationale, + HarnessActor actor) { + final String id = requireText(submissionId, "submission id"); + if (verdict == null) throw new HarnessValidationException("review verdict is required"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ReviewRecord apply(HarnessState state) { + SubmissionRecord submission = state.getSubmissions().get(id); + if (submission == null) throw new HarnessValidationException("submission not found: " + id); + TaskRecord task = requireTask(state, submission.getTaskId()); + if (!id.equals(task.getSubmissionId())) { + throw new HarnessConflictException("submission is no longer the current task submission: " + id); + } + String executionId = trimToNull(submission.getExecutionId()); + if (executionId != null) { + ExecutionRecord execution = requireExecution(state, executionId); + if (!submission.getTaskId().equals(execution.getTaskId())) { + throw new HarnessValidationException("submission execution does not belong to task: " + + submission.getTaskId()); + } + assertCurrentTaskExecution(task, executionId); + assertSameScope(task.getScopeKey(), execution.getScopeKey(), + "submission and execution scopes must match"); + } + if (!contract.mayApprove(effectiveActor, submission)) { + throw new HarnessValidationException("actor is not allowed to review this submission"); + } + String reviewId = valueOrGenerated(null, "review_"); + ReviewRecord review = ReviewRecord.builder() + .reviewId(reviewId) + .submissionId(id) + .taskId(submission.getTaskId()) + .reviewer(copyActor(effectiveActor)) + .verdict(verdict) + .findings(findings) + .rationale(rationale) + .createdAtEpochMs(now()) + .build(); + state.getReviews().put(reviewId, review); + if (task != null && verdict == ReviewVerdict.CHANGES_REQUESTED) { + task.setStatus(TaskStatus.ACTIVE); + task.setUpdatedAtEpochMs(now()); + task.setVersion(task.getVersion() + 1L); + } + addEvent(state, "review.recorded", reviewId, effectiveActor, mapOf( + "submissionId", id, "verdict", verdict)); + return review.copy(); + } + }); + } + + public TaskRecord completeTask(String taskId, String submissionId, HarnessActor actor) { + final String id = requireText(taskId, "task id"); + final String submissionKey = requireText(submissionId, "submission id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + final CompletionFailure failure = new CompletionFailure(); + TaskRecord result = write(new StateCommand() { + @Override + public TaskRecord apply(HarnessState state) { + TaskRecord task = requireTask(state, id); + SubmissionRecord submission = state.getSubmissions().get(submissionKey); + if (submission == null) { + throw new HarnessValidationException("submission not found: " + submissionKey); + } + if (!id.equals(submission.getTaskId())) { + throw new HarnessValidationException("submission does not belong to task: " + id); + } + if (!submissionKey.equals(task.getSubmissionId())) { + throw new HarnessConflictException("submission is no longer the current task submission: " + + submissionKey); + } + String executionId = trimToNull(submission.getExecutionId()); + if (executionId == null) { + throw new HarnessValidationException("submission must reference an execution before completion"); + } + ExecutionRecord execution = state.getExecutions().get(executionId); + if (execution == null) { + throw new HarnessValidationException("submission execution not found: " + executionId); + } + if (!id.equals(execution.getTaskId())) { + throw new HarnessValidationException("submission execution does not belong to task: " + id); + } + assertCurrentTaskExecution(task, executionId); + assertSameScope(task.getScopeKey(), execution.getScopeKey(), + "submission and execution scopes must match"); + if (!ExecutionStatus.SUCCEEDED.equals(execution.getStatus())) { + throw new HarnessConflictException("submission execution must be SUCCEEDED before completion: " + + executionId); + } + if (!dependenciesSatisfied(state, id)) { + throw new HarnessConflictException("task dependencies are not complete: " + id); + } + if (!contract.mayComplete(effectiveActor, submission)) { + throw new HarnessValidationException("actor is not allowed to complete this task"); + } + if (contract.requiresApprovedReview(task, submission) + && !hasApprovedReview(state, submission.getSubmissionId())) { + failure.reason = "an approved review is required before completion"; + return task.copy(); + } + List gateResults = contract.evaluateCompletion(task, submission, state); + boolean passed = gateResults != null && !gateResults.isEmpty(); + String failedReason = null; + if (gateResults != null) { + for (GateResult gateResult : gateResults) { + if (gateResult == null || !gateResult.isPassed()) { + passed = false; + if (failedReason == null) { + failedReason = gateResult == null ? "gate returned no result" : gateResult.getReason(); + } + } + String gateName = gateResult == null ? "unknown" : gateResult.getName(); + String gateId = valueOrGenerated(null, "gate_"); + state.getGates().put(gateId, GateRecord.builder() + .gateId(gateId) + .taskId(id) + .name(gateName) + .status(gateResult != null && gateResult.isPassed() ? GateStatus.PASS : GateStatus.FAIL) + .reason(gateResult == null ? "gate returned no result" : gateResult.getReason()) + .evaluatedAtEpochMs(now()) + .build()); + } + } + if (!passed) { + failure.reason = failedReason == null ? "completion gates did not pass" : failedReason; + addEvent(state, "task.completion_rejected", id, effectiveActor, mapOf("reason", failure.reason)); + return task.copy(); + } + task.setStatus(TaskStatus.DONE); + task.setBlockedReason(null); + task.setUpdatedAtEpochMs(now()); + task.setVersion(task.getVersion() + 1L); + addEvent(state, "task.completed", id, effectiveActor, mapOf("submissionId", submissionKey, + "executionId", executionId)); + return task.copy(); + } + }); + if (failure.reason != null) { + throw new HarnessValidationException(failure.reason); + } + return result; + } + + public ExecutionRecord createExecution(HarnessExecutionSpec spec) { + return createExecution(spec, defaultActor); + } + + public ExecutionRecord createExecution(HarnessExecutionSpec spec, HarnessActor actor) { + if (spec == null) throw new HarnessValidationException("execution specification is required"); + final String executionId = valueOrGenerated(spec.getExecutionId(), "exe_"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + String executionScope = executionSpecScope(state, spec); + String existingId = idempotentId(state, IDEMPOTENCY_EXECUTION, executionScope, + spec.getIdempotencyKey()); + if (existingId != null && state.getExecutions().get(existingId) != null) { + return state.getExecutions().get(existingId).copy(); + } + if (state.getExecutions().containsKey(executionId)) { + throw new HarnessConflictException("execution already exists: " + executionId); + } + String executionTaskId = trimToNull(spec.getTaskId()); + if (executionTaskId != null) { + TaskRecord task = requireTask(state, executionTaskId); + if (TaskStatus.DONE.equals(task.getStatus()) || TaskStatus.CANCELLED.equals(task.getStatus())) { + throw new HarnessValidationException("cannot execute a terminal task: " + executionTaskId); + } + if (TaskStatus.BLOCKED.equals(task.getStatus()) + || TaskStatus.IN_REVIEW.equals(task.getStatus())) { + throw new HarnessConflictException("task cannot create an execution in status " + + task.getStatus() + ": " + executionTaskId); + } + if (!dependenciesSatisfied(state, executionTaskId)) { + throw new HarnessConflictException("task dependencies are not complete: " + executionTaskId); + } + if (hasOutstandingExecution(state, executionTaskId)) { + throw new HarnessConflictException("task already has an outstanding execution: " + + executionTaskId); + } + } + int attempt = nextAttempt(state, executionTaskId); + ExecutionRecord execution = ExecutionRecord.builder() + .executionId(executionId) + .taskId(executionTaskId) + .scopeKey(executionScope) + .sessionId(trimToNull(spec.getSessionId())) + .runId(resolveExecutionRunId(state, spec)) + .status(ExecutionStatus.READY) + .attempt(attempt) + .inputSummary(spec.getInputSummary()) + .createdAtEpochMs(now()) + .updatedAtEpochMs(now()) + .version(1L) + .build(); + state.getExecutions().put(executionId, execution); + if (execution.getTaskId() != null) { + TaskRecord task = state.getTasks().get(execution.getTaskId()); + if (task != null) { + task.setLastExecutionId(executionId); + task.setUpdatedAtEpochMs(now()); + task.setVersion(task.getVersion() + 1L); + } + } + rememberIdempotency(state, IDEMPOTENCY_EXECUTION, executionScope, + spec.getIdempotencyKey(), executionId); + addEvent(state, "execution.created", executionId, effectiveActor, mapOf( + "taskId", execution.getTaskId(), "attempt", attempt)); + return execution.copy(); + } + }); + } + + public ExecutionRecord attachExecutionToTask(String executionId, String taskId) { + return attachExecutionToTask(executionId, taskId, defaultActor); + } + + public ExecutionRecord attachExecutionToTask(String executionId, String taskId, HarnessActor actor) { + final String executionKey = requireText(executionId, "execution id"); + final String taskKey = requireText(taskId, "task id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + return attachExecutionToTaskInternal(state, executionKey, taskKey, effectiveActor); + } + }); + } + + public ExecutionRecord claimExecution(String executionId, String workerId, long leaseDurationMillis) { + final String executionKey = requireText(executionId, "execution id"); + final String worker = requireText(workerId, "worker id"); + final long duration = leaseDurationMillis <= 0L ? 60_000L : leaseDurationMillis; + final ConflictHolder conflict = new ConflictHolder(); + ExecutionRecord result = write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + TaskRecord task = execution.getTaskId() == null + ? null : state.getTasks().get(execution.getTaskId()); + if (task != null && (TaskStatus.CANCELLED.equals(task.getStatus()) + || TaskStatus.DONE.equals(task.getStatus()) + || TaskStatus.BLOCKED.equals(task.getStatus()) + || TaskStatus.IN_REVIEW.equals(task.getStatus()))) { + throw new HarnessConflictException("cannot claim execution for task in status " + + task.getStatus() + ": " + execution.getTaskId()); + } + long currentTime = now(); + LeaseRecord currentLease = execution.getLeaseId() == null ? null : state.getLeases().get(execution.getLeaseId()); + if (ExecutionStatus.RUNNING.equals(execution.getStatus()) && currentLease != null + && !currentLease.isExpired(currentTime)) { + if (worker.equals(currentLease.getWorkerId())) { + if (!ensureSessionLeaseForRunning(state, execution, currentLease, currentTime)) { + conflict.message = "session lease expired and execution was marked UNKNOWN: " + executionKey; + } + return execution.copy(); + } + throw new HarnessConflictException("execution is already leased by " + currentLease.getWorkerId()); + } + if (ExecutionStatus.RUNNING.equals(execution.getStatus()) + && (currentLease == null || currentLease.isExpired(currentTime))) { + markExecutionUnknown(state, execution, currentTime, + currentLease == null + ? "execution lease is missing; side effects require reconciliation" + : "execution lease expired; side effects require reconciliation", + HarnessActor.worker(worker)); + conflict.message = "execution lease expired and was marked UNKNOWN: " + executionKey; + return execution.copy(); + } + if (ExecutionStatus.UNKNOWN.equals(execution.getStatus())) { + throw new HarnessConflictException("execution is UNKNOWN and needs explicit reconciliation: " + executionKey); + } + if (ExecutionStatus.WAITING.equals(execution.getStatus())) { + throw new HarnessConflictException("execution is waiting for an external wakeup: " + executionKey); + } + if (!ExecutionStatus.READY.equals(execution.getStatus())) { + throw new HarnessConflictException("execution cannot be claimed from status " + execution.getStatus()); + } + if (trimToNull(execution.getSessionId()) == null) { + // A low-level caller may create an unbound Execution. Bind + // its runtime Session identity at the same durable + // mutation that acquires the execution lease so the + // adapter snapshot can be validated and recovered later. + execution.setSessionId(valueOrGenerated(null, "session_")); + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + addEvent(state, "execution.session_bound", executionKey, HarnessActor.worker(worker), + mapOf("sessionId", execution.getSessionId())); + } + long token = nextFencingToken(state); + String leaseId = valueOrGenerated(null, "lease_"); + LeaseRecord lease = LeaseRecord.builder() + .leaseId(leaseId) + .executionId(executionKey) + .workerId(worker) + .fencingToken(token) + .acquiredAtEpochMs(currentTime) + .expiresAtEpochMs(currentTime + duration) + .build(); + acquireSessionLease(state, execution, worker, leaseId, token, + currentTime, currentTime + duration); + state.getLeases().put(leaseId, lease); + execution.setStatus(ExecutionStatus.RUNNING); + execution.setWorkerId(worker); + execution.setLeaseId(leaseId); + execution.setFencingToken(token); + execution.setStartedAtEpochMs(execution.getStartedAtEpochMs() <= 0L ? currentTime : execution.getStartedAtEpochMs()); + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + if (task != null) { + if (TaskStatus.PLANNED.equals(task.getStatus()) || TaskStatus.WAITING.equals(task.getStatus())) { + task.setStatus(TaskStatus.ACTIVE); + task.setBlockedReason(null); + task.setUpdatedAtEpochMs(currentTime); + task.setVersion(task.getVersion() + 1L); + } + } + addEvent(state, "execution.claimed", executionKey, HarnessActor.worker(worker), mapOf( + "workerId", worker, "fencingToken", token)); + return execution.copy(); + } + }); + if (conflict.message != null) { + throw new HarnessConflictException(conflict.message); + } + return result; + } + + /** + * Fences a worker result after its execution or session lease was lost. + * This mutation is intentionally separate from {@link #persistExecutionOutcome} + * because the latter must reject a stale worker before it can write an + * outcome. The method never overwrites a newer owner: the execution and + * lease tuple is checked again inside the durable mutation. + */ + public ExecutionRecord markExecutionUnknownIfLeaseLost(String executionId, + String leaseId, + long fencingToken, + String workerId, + String reason) { + final String executionKey = requireText(executionId, "execution id"); + final String leaseKey = requireText(leaseId, "lease id"); + final String worker = requireText(workerId, "worker id"); + final String unknownReason = trimToNull(reason) == null + ? "execution lease was lost; side effects require reconciliation" : reason.trim(); + final ConflictHolder conflict = new ConflictHolder(); + ExecutionRecord result = write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + if (!ExecutionStatus.RUNNING.equals(execution.getStatus())) { + return execution.copy(); + } + if (!safeEquals(leaseKey, execution.getLeaseId()) + || execution.getFencingToken() != fencingToken + || !safeEquals(worker, execution.getWorkerId())) { + conflict.message = "execution lease was already fenced or reclaimed: " + executionKey; + return execution.copy(); + } + long currentTime = now(); + LeaseRecord lease = state.getLeases().get(leaseKey); + SessionLeaseRecord sessionLease = execution.getSessionId() == null + ? null : state.getSessionLeases().get(execution.getSessionId()); + boolean executionLeaseLost = lease == null || lease.isExpired(currentTime) + || lease.getFencingToken() != fencingToken + || !safeEquals(worker, lease.getWorkerId()); + boolean sessionLeaseLost = execution.getSessionId() != null + && (sessionLease == null || sessionLease.isExpired(currentTime) + || !sameSessionLease(sessionLease, execution, leaseKey, fencingToken, worker)); + if (!executionLeaseLost && !sessionLeaseLost) { + conflict.message = "execution lease is still active; outcome was not persisted: " + executionKey; + return execution.copy(); + } + markExecutionUnknown(state, execution, currentTime, unknownReason, + HarnessActor.worker(worker)); + return execution.copy(); + } + }); + if (conflict.message != null) { + throw new HarnessConflictException(conflict.message); + } + return result; + } + + public ExecutionRecord heartbeat(String executionId, String leaseId, long fencingToken, String workerId, + long leaseDurationMillis) { + final String executionKey = requireText(executionId, "execution id"); + final String leaseKey = requireText(leaseId, "lease id"); + final String worker = requireText(workerId, "worker id"); + final long duration = leaseDurationMillis <= 0L ? 60_000L : leaseDurationMillis; + return write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + assertLease(state, execution, leaseKey, fencingToken, worker); + LeaseRecord lease = state.getLeases().get(leaseKey); + long currentTime = now(); + lease.setExpiresAtEpochMs(currentTime + duration); + if (!refreshSessionLease(state, execution, lease, currentTime)) { + return execution.copy(); + } + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + // The lease timestamps are the durable heartbeat record. Do + // not append one full HarnessState event for every heartbeat; + // long-lived agents would otherwise grow the ledger without + // adding a recovery decision. + return execution.copy(); + } + }); + } + + public ExecutionRecord releaseExecution(String executionId, String leaseId, long fencingToken, String workerId) { + final String executionKey = requireText(executionId, "execution id"); + final String leaseKey = requireText(leaseId, "lease id"); + final String worker = requireText(workerId, "worker id"); + return write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + assertLease(state, execution, leaseKey, fencingToken, worker); + LeaseRecord lease = state.getLeases().get(leaseKey); + long currentTime = now(); + lease.setReleasedAtEpochMs(currentTime); + releaseSessionLease(state, execution, leaseKey, fencingToken, worker, currentTime); + TaskRecord task = execution.getTaskId() == null + ? null : state.getTasks().get(execution.getTaskId()); + boolean cancelled = task != null && TaskStatus.CANCELLED.equals(task.getStatus()); + execution.setStatus(cancelled ? ExecutionStatus.CANCELLED : ExecutionStatus.READY); + execution.setWorkerId(null); + execution.setLeaseId(null); + execution.setFencingToken(0L); + if (cancelled) { + execution.setWaitId(null); + execution.setOperationId(null); + execution.setError(task.getBlockedReason() == null + ? "task was cancelled while the execution was running" + : task.getBlockedReason()); + execution.setFinishedAtEpochMs(currentTime); + } + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + addEvent(state, cancelled ? "execution.cancelled" : "execution.requeued", executionKey, + HarnessActor.worker(worker), mapOf("taskId", execution.getTaskId())); + return execution.copy(); + } + }); + } + + public ExecutionRecord reconcileExecution(String executionId, + ExecutionStatus resolution, + String reason) { + return reconcileExecution(executionId, resolution, reason, null, defaultActor); + } + + public ExecutionRecord reconcileExecution(String executionId, + ExecutionStatus resolution, + String reason, + HarnessActor actor) { + return reconcileExecution(executionId, resolution, reason, null, actor); + } + + /** + * Resolves an UNKNOWN execution after an operator or an idempotent + * external lookup has established what happened. A READY resolution + * permits a retry; terminal resolutions record the external outcome while + * leaving Task completion behind the normal Submission/Review/Gate path. + */ + public ExecutionRecord reconcileExecution(String executionId, + ExecutionStatus resolution, + String reason, + String outputText, + HarnessActor actor) { + final String executionKey = requireText(executionId, "execution id"); + if (resolution == null || resolution == ExecutionStatus.RUNNING + || resolution == ExecutionStatus.WAITING + || resolution == ExecutionStatus.UNKNOWN) { + throw new HarnessValidationException( + "execution reconciliation must resolve to READY, SUCCEEDED, FAILED, or CANCELLED"); + } + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + if (!ExecutionStatus.UNKNOWN.equals(execution.getStatus())) { + throw new HarnessConflictException( + "only UNKNOWN executions can be reconciled: " + executionKey); + } + if (!contract.mayReconcile(effectiveActor, execution, resolution)) { + throw new HarnessValidationException("actor is not allowed to reconcile this execution"); + } + long currentTime = now(); + LeaseRecord lease = execution.getLeaseId() == null + ? null : state.getLeases().get(execution.getLeaseId()); + releaseSessionLease(state, execution, execution.getLeaseId(), + execution.getFencingToken(), execution.getWorkerId(), currentTime); + if (lease != null) { + lease.setReleasedAtEpochMs(currentTime); + } + execution.setStatus(resolution); + execution.setWorkerId(null); + execution.setLeaseId(null); + execution.setFencingToken(0L); + execution.setWaitId(null); + execution.setOperationId(null); + if (outputText != null) { + execution.setOutputText(outputText); + } + execution.setError(ExecutionStatus.SUCCEEDED.equals(resolution) ? null : reason); + execution.setFinishedAtEpochMs(isTerminalExecution(resolution) ? currentTime : 0L); + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + TaskRecord task = execution.getTaskId() == null + ? null : state.getTasks().get(execution.getTaskId()); + if (task != null) { + task.setLastExecutionId(executionKey); + if (!TaskStatus.DONE.equals(task.getStatus()) + && !TaskStatus.CANCELLED.equals(task.getStatus()) + && !TaskStatus.IN_REVIEW.equals(task.getStatus())) { + task.setStatus(TaskStatus.ACTIVE); + task.setBlockedReason(null); + task.setUpdatedAtEpochMs(currentTime); + task.setVersion(task.getVersion() + 1L); + } + } + addEvent(state, "execution.reconciled", executionKey, effectiveActor, mapOf( + "resolution", resolution, "reason", reason)); + return execution.copy(); + } + }); + } + + public ExecutionRecord persistExecutionOutcome(HarnessExecutionOutcome outcome) { + if (outcome == null) throw new HarnessValidationException("execution outcome is required"); + final String executionKey = requireText(outcome.getExecutionId(), "execution id"); + if (outcome.getStatus() == null) throw new HarnessValidationException("execution outcome status is required"); + if (ExecutionStatus.RUNNING.equals(outcome.getStatus())) { + throw new HarnessValidationException( + "RUNNING is not a persistable execution outcome; return READY, WAITING, or a terminal status"); + } + if (!ExecutionStatus.WAITING.equals(outcome.getStatus()) + && (trimToNull(outcome.getWaitId()) != null + || trimToNull(outcome.getOperationId()) != null)) { + throw new HarnessValidationException( + "only a WAITING execution outcome may reference a wait or operation"); + } + return write(new StateCommand() { + @Override + public ExecutionRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + assertLease(state, execution, outcome.getLeaseId(), outcome.getFencingToken(), execution.getWorkerId()); + assertSnapshotMatchesExecution(execution, outcome.getSessionSnapshot()); + String workerId = execution.getWorkerId(); + long currentTime = now(); + TaskRecord task = execution.getTaskId() == null ? null : state.getTasks().get(execution.getTaskId()); + ExecutionStatus originalStatus = outcome.getStatus(); + ExecutionStatus persistedStatus = outcome.getStatus(); + String persistedError = outcome.getError(); + boolean cancelledByTask = task != null && TaskStatus.CANCELLED.equals(task.getStatus()); + boolean quarantinedLateOutcome = cancelledByTask + && !ExecutionStatus.CANCELLED.equals(persistedStatus); + if (quarantinedLateOutcome) { + persistedStatus = ExecutionStatus.CANCELLED; + String lateStatus = originalStatus == null ? "UNKNOWN" : originalStatus.name(); + String cancellationMessage = "task was cancelled while the execution was running; " + + "late outcome was " + lateStatus + + " and any external side effect requires reconciliation"; + if (persistedError == null || persistedError.trim().isEmpty()) { + persistedError = cancellationMessage; + } else { + persistedError = cancellationMessage + ": " + persistedError; + } + } + if (!cancelledByTask && outcome.getSessionSnapshot() != null + && outcome.getSessionSnapshot().getSessionId() != null) { + state.getSessions().put(outcome.getSessionSnapshot().getSessionId(), outcome.getSessionSnapshot()); + } + String waitId = outcome.getWaitId(); + String operationId = outcome.getOperationId(); + if (!cancelledByTask && ExecutionStatus.WAITING.equals(persistedStatus)) { + WaitRecord requestedWait = waitId == null || waitId.trim().isEmpty() + ? null : state.getWaits().get(waitId.trim()); + if (requestedWait != null && !executionKey.equals(requestedWait.getExecutionId())) { + throw new HarnessConflictException("wait id is already bound to another execution: " + waitId); + } + List openWaitRecords = openWaits(state, executionKey); + if (openWaitRecords.isEmpty()) { + // A runtime may report WAITING without creating a + // Harness wait. Reuse a fresh requested id when it is + // available; never reopen a delivered/cancelled wait. + String newWaitId = requestedWait == null + ? (waitId == null || waitId.trim().isEmpty() + ? valueOrGenerated(null, "wait_") : waitId.trim()) + : valueOrGenerated(null, "wait_"); + WaitRecord wait = WaitRecord.builder() + .waitId(newWaitId) + .executionId(executionKey) + .taskId(execution.getTaskId()) + .type(WaitType.ASYNC_OPERATION) + .status(WaitStatus.OPEN) + .operationId(operationId) + .createdAtEpochMs(currentTime) + .payload(new LinkedHashMap()) + .build(); + state.getWaits().put(newWaitId, wait); + openWaitRecords = openWaits(state, executionKey); + } + WaitRecord representative = openWaitRecords.get(0); + waitId = representative.getWaitId(); + operationId = representative.getOperationId() == null + ? operationId : representative.getOperationId(); + } else { + waitId = null; + operationId = null; + cancelOpenWaitsForExecution(state, executionKey, + "execution outcome no longer waits for external input", HarnessActor.worker(workerId), + currentTime); + } + String checkpointId = cancelledByTask ? execution.getCheckpointId() : null; + if (!cancelledByTask && (outcome.getSessionSnapshot() != null || outcome.getCheckpointSummary() != null + || (outcome.getCheckpointState() != null && !outcome.getCheckpointState().isEmpty()))) { + checkpointId = valueOrGenerated(null, "checkpoint_"); + Map checkpointState = copyMap(outcome.getCheckpointState()); + if (checkpointState == null) { + checkpointState = new LinkedHashMap(); + } + checkpointState.put("waitId", waitId); + checkpointState.put("operationId", operationId); + CheckpointRecord checkpoint = CheckpointRecord.builder() + .checkpointId(checkpointId) + .executionId(executionKey) + .taskId(execution.getTaskId()) + .sessionId(execution.getSessionId()) + .runId(execution.getRunId()) + .summary(outcome.getCheckpointSummary()) + .createdAtEpochMs(currentTime) + .state(checkpointState) + .build(); + state.getCheckpoints().put(checkpointId, checkpoint); + } + execution.setStatus(persistedStatus); + execution.setWaitId(ExecutionStatus.WAITING.equals(persistedStatus) ? waitId : null); + execution.setOperationId(ExecutionStatus.WAITING.equals(persistedStatus) ? operationId : null); + execution.setCheckpointId(checkpointId); + execution.setOutputText(outcome.getOutputText()); + execution.setError(persistedError); + execution.setUpdatedAtEpochMs(currentTime); + execution.setFinishedAtEpochMs(isTerminalExecution(persistedStatus) ? currentTime : 0L); + execution.setVersion(execution.getVersion() + 1L); + LeaseRecord lease = state.getLeases().get(outcome.getLeaseId()); + if (lease != null) lease.setReleasedAtEpochMs(currentTime); + releaseSessionLease(state, execution, outcome.getLeaseId(), + outcome.getFencingToken(), workerId, currentTime); + execution.setWorkerId(null); + execution.setLeaseId(null); + execution.setFencingToken(0L); + if (task != null) { + task.setLastExecutionId(executionKey); + if (cancelledByTask) { + // Cancellation is a terminal handoff boundary. A late + // worker result must never reactivate the Task. + } else if (ExecutionStatus.WAITING.equals(persistedStatus)) { + task.setStatus(TaskStatus.WAITING); + } else if (ExecutionStatus.UNKNOWN.equals(persistedStatus)) { + task.setStatus(TaskStatus.BLOCKED); + task.setBlockedReason(persistedError); + } else if (!TaskStatus.DONE.equals(task.getStatus()) + && !TaskStatus.CANCELLED.equals(task.getStatus()) + && !TaskStatus.IN_REVIEW.equals(task.getStatus())) { + task.setStatus(TaskStatus.ACTIVE); + task.setBlockedReason(null); + } + task.setUpdatedAtEpochMs(currentTime); + task.setVersion(task.getVersion() + 1L); + } + addEvent(state, quarantinedLateOutcome ? "execution.outcome_quarantined" + : "execution." + persistedStatus.name().toLowerCase(), executionKey, + HarnessActor.worker(workerId), mapOf("taskId", execution.getTaskId(), "waitId", waitId, + "checkpointId", checkpointId, "error", persistedError, + "outcomeStatus", originalStatus)); + return execution.copy(); + } + }); + } + + public CheckpointRecord recordCheckpoint(String executionId, + String summary, + Map checkpointState) { + return recordCheckpoint(executionId, summary, checkpointState, defaultActor); + } + + public CheckpointRecord recordCheckpoint(String executionId, + String summary, + Map checkpointState, + HarnessActor actor) { + final String executionKey = requireText(executionId, "execution id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public CheckpointRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + String checkpointId = valueOrGenerated(null, "checkpoint_"); + CheckpointRecord checkpoint = CheckpointRecord.builder() + .checkpointId(checkpointId) + .executionId(executionKey) + .taskId(execution.getTaskId()) + .sessionId(execution.getSessionId()) + .runId(execution.getRunId()) + .summary(summary) + .createdAtEpochMs(now()) + .state(copyMap(checkpointState)) + .build(); + state.getCheckpoints().put(checkpointId, checkpoint); + execution.setCheckpointId(checkpointId); + execution.setUpdatedAtEpochMs(now()); + addEvent(state, "checkpoint.created", checkpointId, effectiveActor, mapOf("executionId", executionKey)); + return checkpoint.copy(); + } + }); + } + + public WaitRecord ensureWait(String executionId, + String taskId, + String waitId, + WaitType type, + String operationId, + String externalKey, + Map payload) { + return ensureWait(executionId, taskId, waitId, type, operationId, externalKey, payload, defaultActor); + } + + public WaitRecord ensureWait(String executionId, + String taskId, + String waitId, + WaitType type, + String operationId, + String externalKey, + Map payload, + HarnessActor actor) { + final String executionKey = requireText(executionId, "execution id"); + final String requestedWaitId = waitId == null || waitId.trim().isEmpty() + ? valueOrGenerated(null, "wait_") : waitId.trim(); + final WaitType waitType = type == null ? WaitType.EXTERNAL_EVENT : type; + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public WaitRecord apply(HarnessState state) { + return ensureWaitInternal(state, executionKey, taskId, requestedWaitId, + waitType, operationId, externalKey, payload, effectiveActor); + } + }); + } + + private WaitRecord ensureWaitInternal(HarnessState state, + String executionKey, + String taskId, + String requestedWaitId, + WaitType waitType, + String operationId, + String externalKey, + Map payload, + HarnessActor actor) { + ExecutionRecord execution = requireExecution(state, executionKey); + WaitRecord existing = state.getWaits().get(requestedWaitId); + if (existing != null) { + if (!executionKey.equals(existing.getExecutionId())) { + throw new HarnessConflictException("wait id is already bound to another execution: " + + requestedWaitId); + } + return existing.copy(); + } + String effectiveTaskId = taskId == null ? execution.getTaskId() : trimToNull(taskId); + if (effectiveTaskId != null) { + if (!safeEquals(effectiveTaskId, execution.getTaskId())) { + throw new HarnessConflictException("wait task does not belong to execution: " + + executionKey); + } + TaskRecord task = requireTask(state, effectiveTaskId); + assertCompatibleScope(task.getScopeKey(), execution.getScopeKey(), + "wait and execution scopes must match"); + if (TaskStatus.DONE.equals(task.getStatus()) || TaskStatus.CANCELLED.equals(task.getStatus())) { + throw new HarnessConflictException("cannot create a wait for a terminal task: " + + effectiveTaskId); + } + if (TaskStatus.BLOCKED.equals(task.getStatus()) + || TaskStatus.IN_REVIEW.equals(task.getStatus())) { + throw new HarnessConflictException("cannot create a wait for task in status " + + task.getStatus() + ": " + effectiveTaskId); + } + } + long currentTime = now(); + WaitRecord wait = WaitRecord.builder() + .waitId(requestedWaitId) + .executionId(executionKey) + .taskId(effectiveTaskId) + .type(waitType) + .status(WaitStatus.OPEN) + .operationId(operationId) + .externalKey(externalKey) + .createdAtEpochMs(currentTime) + .payload(copyMap(payload)) + .build(); + state.getWaits().put(requestedWaitId, wait); + WaitRecord representative = firstOpenWait(state, executionKey); + execution.setWaitId(representative == null ? requestedWaitId : representative.getWaitId()); + execution.setOperationId(representative == null ? operationId : representative.getOperationId()); + execution.setUpdatedAtEpochMs(currentTime); + if (effectiveTaskId != null) { + TaskRecord task = state.getTasks().get(effectiveTaskId); + if (task != null && !TaskStatus.DONE.equals(task.getStatus()) && !TaskStatus.CANCELLED.equals(task.getStatus())) { + task.setStatus(TaskStatus.WAITING); + task.setUpdatedAtEpochMs(currentTime); + task.setVersion(task.getVersion() + 1L); + } + } + addEvent(state, "wait.created", requestedWaitId, actor, mapOf( + "executionId", executionKey, "type", waitType, "operationId", operationId)); + return wait.copy(); + } + + public WaitRecord requestApproval(String executionId, + String taskId, + String toolName, + String callId, + String arguments) { + return requestApproval(executionId, taskId, toolName, callId, arguments, defaultActor); + } + + public WaitRecord requestApproval(String executionId, + String taskId, + String toolName, + String callId, + String arguments, + HarnessActor actor) { + return requestApproval(executionId, taskId, toolName, callId, arguments, + null, actor); + } + + /** + * Requests approval and, when supplied, atomically links a pre-reserved + * business invocation to the approval wait. This closes the crash window + * between creating the wait and marking the invocation as retryable. + */ + public WaitRecord requestApproval(String executionId, + String taskId, + String toolName, + String callId, + String arguments, + String invocationId, + HarnessActor actor) { + final String executionKey = requireText(executionId, "execution id"); + final String invocationKey = trimToNull(invocationId); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public WaitRecord apply(HarnessState state) { + ExecutionRecord execution = requireExecution(state, executionKey); + ToolInvocationRecord invocation = invocationKey == null + ? null : requireApprovalInvocation(state, invocationKey, execution, + toolName, callId, arguments); + for (WaitRecord wait : state.getWaits().values()) { + if (wait == null || !WaitType.APPROVAL.equals(wait.getType()) + || !executionKey.equals(wait.getExecutionId())) continue; + Map existingPayload = wait.getPayload(); + if (existingPayload == null) { + existingPayload = new LinkedHashMap(); + } + if (safeEquals(toolName, String.valueOf(existingPayload.get("toolName"))) + && approvalMatches(existingPayload, callId, arguments) + && WaitStatus.OPEN.equals(wait.getStatus())) { + if (invocation != null) { + existingPayload.put(AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID, + invocationKey); + wait.setPayload(existingPayload); + linkApprovalInvocation(invocation, wait, effectiveActor, state); + } + return wait.copy(); + } + } + Map payload = new LinkedHashMap(); + payload.put("toolName", toolName); + payload.put("callId", callId); + payload.put("arguments", arguments); + payload.put("approval", true); + if (invocationKey != null) { + payload.put(AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID, invocationKey); + } + WaitRecord wait = ensureWaitInternal(state, executionKey, taskId, + valueOrGenerated(null, "wait_"), WaitType.APPROVAL, null, + toolName, payload, effectiveActor); + if (invocation != null) { + linkApprovalInvocation(invocation, wait, effectiveActor, state); + } + return wait; + } + }); + } + + public boolean isApprovalGranted(String executionId, String toolName, String callId) { + return isApprovalGranted(executionId, toolName, callId, null); + } + + /** + * Checks a delivered approval without allowing it to be reused for a + * different invocation. Providers may change call ids across a retry, so + * an exact argument match is also accepted. + */ + public boolean isApprovalGranted(String executionId, + String toolName, + String callId, + String arguments) { + HarnessState state = getState(); + for (WaitRecord wait : state.getWaits().values()) { + if (wait == null || !WaitType.APPROVAL.equals(wait.getType()) + || !safeEquals(executionId, wait.getExecutionId()) + || !WaitStatus.DELIVERED.equals(wait.getStatus())) continue; + Map payload = wait.getPayload(); + if (payload == null || !safeEquals(toolName, String.valueOf(payload.get("toolName")))) continue; + if (deliveryApproved(payload.get("delivery")) + && approvalMatches(payload, callId, arguments)) return true; + } + return false; + } + + public WakeupRecord deliverWait(String waitId, Object input) { + return deliverWait(waitId, input, null, defaultActor); + } + + public WakeupRecord deliverWait(String waitId, Object input, HarnessActor actor) { + return deliverWait(waitId, input, null, actor); + } + + /** + * Delivers a wait and optionally persists the resumed Agent session in the + * same state mutation. This is the durable handoff used by AgentHarness. + */ + public WakeupRecord deliverWait(String waitId, + Object input, + AgentSessionSnapshot sessionSnapshot, + HarnessActor actor) { + return deliverWaitInternal(waitId, input, sessionSnapshot, null, actor); + } + + /** + * Delivers a wait and updates an adapter-owned recovery payload in the + * same ledger mutation. This keeps a replacement tool result durable + * before a resumed adapter is opened. + */ + public WakeupRecord deliverAdapterWait(String waitId, + Object input, + HarnessAdapterState adapterState, + HarnessActor actor) { + return deliverAdapterWait(waitId, input, adapterState, null, actor); + } + + /** + * Delivers an adapter wait while optionally updating the legacy Agent + * session projection in the same state mutation. The projection is kept + * only for compatibility with callers that still inspect + * {@link #getSessionSnapshot(String)}; the adapter state remains the + * generic recovery source. + */ + public WakeupRecord deliverAdapterWait(String waitId, + Object input, + HarnessAdapterState adapterState, + AgentSessionSnapshot sessionSnapshot, + HarnessActor actor) { + return deliverWaitInternal(waitId, input, sessionSnapshot, adapterState, actor); + } + + private WakeupRecord deliverWaitInternal(String waitId, + Object input, + AgentSessionSnapshot sessionSnapshot, + HarnessAdapterState adapterState, + HarnessActor actor) { + final String id = requireText(waitId, "wait id"); + final HarnessActor effectiveActor = normalizeActor(actor, defaultActor); + return write(new StateCommand() { + @Override + public WakeupRecord apply(HarnessState state) { + WaitRecord wait = state.getWaits().get(id); + if (wait == null) throw new HarnessValidationException("wait not found: " + id); + if (WaitStatus.DELIVERED.equals(wait.getStatus())) { + for (WakeupRecord existing : state.getWakeups().values()) { + if (existing != null && id.equals(existing.getWaitId())) return existing.copy(); + } + } + if (!WaitStatus.OPEN.equals(wait.getStatus())) { + throw new HarnessConflictException("wait is not open: " + id); + } + long currentTime = now(); + wait.setStatus(WaitStatus.DELIVERED); + wait.setResolvedAtEpochMs(currentTime); + Map waitPayload = wait.getPayload() == null + ? new LinkedHashMap() : wait.getPayload(); + waitPayload.put("delivery", input); + wait.setPayload(waitPayload); + if (sessionSnapshot != null && sessionSnapshot.getSessionId() != null) { + state.getSessions().put(sessionSnapshot.getSessionId(), + HarnessJson.copy(sessionSnapshot, AgentSessionSnapshot.class)); + } + String wakeupId = valueOrGenerated(null, "wakeup_"); + WakeupRecord wakeup = WakeupRecord.builder() + .wakeupId(wakeupId) + .waitId(id) + .executionId(wait.getExecutionId()) + .type(wait.getType()) + .dueAtEpochMs(wait.getDueAtEpochMs()) + .deliveredAtEpochMs(currentTime) + .payload(copyMap(waitPayload)) + .build(); + state.getWakeups().put(wakeupId, wakeup); + ExecutionRecord execution = state.getExecutions().get(wait.getExecutionId()); + if (adapterState != null && execution != null && execution.getCheckpointId() != null) { + CheckpointRecord checkpoint = state.getCheckpoints().get(execution.getCheckpointId()); + if (checkpoint != null) { + Map checkpointState = checkpoint.getState() == null + ? new LinkedHashMap() + : new LinkedHashMap(checkpoint.getState()); + checkpointState.put("harnessAdapterType", adapterState.getAdapterType()); + checkpointState.put("harnessAdapterState", + JSON.parseObject(JSON.toJSONString(adapterState))); + checkpoint.setState(checkpointState); + } + } + TaskRecord task = wait.getTaskId() == null ? null : state.getTasks().get(wait.getTaskId()); + if (execution != null && (ExecutionStatus.WAITING.equals(execution.getStatus()) + || ExecutionStatus.READY.equals(execution.getStatus()))) { + List remainingWaits = openWaits(state, wait.getExecutionId()); + WaitRecord representative = remainingWaits.isEmpty() + ? null : remainingWaits.get(0); + if (representative == null) { + execution.setStatus(ExecutionStatus.READY); + execution.setWaitId(null); + execution.setOperationId(null); + } else { + // Parallel tool calls may leave more than one durable + // wait. Keep the Execution waiting until all of them + // have been delivered, while exposing one stable next + // wait for hosts that only support a single id. + execution.setStatus(ExecutionStatus.WAITING); + execution.setWaitId(representative.getWaitId()); + execution.setOperationId(representative.getOperationId()); + } + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + } + if (task != null && TaskStatus.WAITING.equals(task.getStatus()) + && execution != null && ExecutionStatus.READY.equals(execution.getStatus())) { + task.setStatus(TaskStatus.ACTIVE); + task.setUpdatedAtEpochMs(currentTime); + task.setVersion(task.getVersion() + 1L); + } + addEvent(state, "wakeup.delivered", wakeupId, effectiveActor, mapOf( + "waitId", id, "executionId", wait.getExecutionId())); + return wakeup.copy(); + } + }); + } + + public Map delivery(String waitId) { + WaitRecord wait = getWait(waitId); + if (wait == null || wait.getPayload() == null) return Collections.emptyMap(); + Object value = wait.getPayload().get("delivery"); + Map result = new LinkedHashMap(); + result.put("value", value); + result.put("waitId", wait.getWaitId()); + result.put("type", wait.getType()); + return result; + } + + @Override + public void close() { + store.close(); + } + + private TaskRecord createTaskInternal(HarnessState state, + HarnessTaskSpec spec, + String taskId, + HarnessActor actor) { + if (state.getTasks().containsKey(taskId)) { + throw new HarnessConflictException("task already exists: " + taskId); + } + String title = requireText(spec.getTitle(), "task title"); + String parentId = trimToNull(spec.getParentTaskId()); + String taskScope = normalizeScope(spec.getScopeKey()); + if (parentId != null) { + TaskRecord parent = requireTask(state, parentId); + if (taskScope != null) { + assertSameScope(taskScope, parent.getScopeKey(), + "child task and parent task scopes must match"); + } + if (taskScope == null) { + taskScope = parent.getScopeKey(); + } + if (wouldCreateCycle(state, RelationType.PARENT_OF, parentId, taskId)) { + throw new HarnessConflictException("parent relation would create a cycle"); + } + } + long currentTime = now(); + TaskRecord task = TaskRecord.builder() + .taskId(taskId) + .scopeKey(taskScope) + .title(title) + .goal(spec.getGoal()) + .plan(spec.getPlan()) + .status(TaskStatus.PLANNED) + .createdBy(actorKey(actor)) + .createdAtEpochMs(currentTime) + .updatedAtEpochMs(currentTime) + .version(1L) + .tags(copyList(spec.getTags())) + .metadata(copyMap(spec.getMetadata())) + .build(); + state.getTasks().put(taskId, task); + if (parentId != null) { + String relationId = valueOrGenerated(null, "rel_"); + RelationRecord relation = RelationRecord.builder() + .relationId(relationId) + .scopeKey(task.getScopeKey()) + .type(RelationType.PARENT_OF) + .fromKind(EntityKind.TASK) + .fromId(parentId) + .toKind(EntityKind.TASK) + .toId(taskId) + .createdBy(actorKey(actor)) + .createdAtEpochMs(currentTime) + .metadata(new LinkedHashMap()) + .build(); + state.getRelations().put(relationId, relation); + addEvent(state, "relation.created", relationId, actor, mapOf( + "type", RelationType.PARENT_OF, "from", parentId, "to", taskId)); + } + addEvent(state, "task.created", taskId, actor, mapOf( + "title", title, "parentTaskId", parentId)); + return task; + } + + private ExecutionRecord attachExecutionToTaskInternal(HarnessState state, + String executionKey, + String taskKey, + HarnessActor actor) { + ExecutionRecord execution = requireExecution(state, executionKey); + TaskRecord task = requireTask(state, taskKey); + if (TaskStatus.DONE.equals(task.getStatus()) || TaskStatus.CANCELLED.equals(task.getStatus())) { + throw new HarnessValidationException("cannot attach execution to a terminal task: " + taskKey); + } + if (TaskStatus.BLOCKED.equals(task.getStatus()) || TaskStatus.IN_REVIEW.equals(task.getStatus())) { + throw new HarnessConflictException("cannot attach execution to task in status " + + task.getStatus() + ": " + taskKey); + } + if (!dependenciesSatisfied(state, taskKey)) { + throw new HarnessConflictException("task dependencies are not complete: " + taskKey); + } + if (execution.getTaskId() != null && !taskKey.equals(execution.getTaskId())) { + throw new HarnessConflictException("execution is already attached to another task: " + executionKey); + } + if (execution.getTaskId() == null && hasOutstandingExecution(state, taskKey, executionKey)) { + throw new HarnessConflictException("task already has an outstanding execution: " + taskKey); + } + if (execution.getScopeKey() != null) { + assertSameScope(execution.getScopeKey(), task.getScopeKey(), + "execution and task scopes must match"); + } + if (execution.getScopeKey() == null) { + execution.setScopeKey(task.getScopeKey()); + } + execution.setTaskId(taskKey); + task.setLastExecutionId(executionKey); + task.setUpdatedAtEpochMs(now()); + task.setVersion(task.getVersion() + 1L); + addEvent(state, "execution.attached", executionKey, actor, mapOf("taskId", taskKey)); + return execution.copy(); + } + + private void validateRelationSpec(HarnessState state, HarnessRelationSpec spec) { + if (spec.getFromKind() == null || spec.getToKind() == null) { + throw new HarnessValidationException("relation endpoints must have kinds"); + } + String fromId = requireText(spec.getFromId(), "relation from id"); + String toId = requireText(spec.getToId(), "relation to id"); + if (spec.getType() == RelationType.PARENT_OF || spec.getType() == RelationType.DEPENDS_ON) { + if (spec.getFromKind() != EntityKind.TASK || spec.getToKind() != EntityKind.TASK) { + throw new HarnessValidationException(spec.getType() + " relations must connect tasks"); + } + } + if (!entityExists(state, spec.getFromKind(), fromId) || !entityExists(state, spec.getToKind(), toId)) { + throw new HarnessValidationException("relation endpoint does not exist"); + } + resolveRelationScope(state, spec); + } + + private String resolveRelationScope(HarnessState state, HarnessRelationSpec spec) { + String explicit = normalizeScope(spec.getScopeKey()); + String fromScope = entityScope(state, spec.getFromKind(), spec.getFromId()); + String toScope = entityScope(state, spec.getToKind(), spec.getToId()); + assertCompatibleScope(fromScope, toScope, + "relation endpoints must use the same scope"); + assertCompatibleScope(explicit, fromScope, + "relation scope does not match its source entity"); + assertCompatibleScope(explicit, toScope, + "relation scope does not match its target entity"); + return explicit == null ? (fromScope == null ? toScope : fromScope) : explicit; + } + + private String taskSpecScope(HarnessState state, HarnessTaskSpec spec) { + String taskScope = normalizeScope(spec.getScopeKey()); + String parentTaskId = trimToNull(spec.getParentTaskId()); + if (parentTaskId == null) { + return taskScope; + } + TaskRecord parent = requireTask(state, parentTaskId); + if (taskScope != null) { + assertSameScope(taskScope, parent.getScopeKey(), + "child task and parent task scopes must match"); + } + return taskScope == null ? normalizeScope(parent.getScopeKey()) : taskScope; + } + + private String executionSpecScope(HarnessState state, HarnessExecutionSpec spec) { + String executionScope = normalizeScope(spec.getScopeKey()); + String taskId = trimToNull(spec.getTaskId()); + if (taskId == null) { + return executionScope; + } + TaskRecord task = requireTask(state, taskId); + if (executionScope != null) { + assertSameScope(executionScope, task.getScopeKey(), + "execution and task scopes must match"); + } + return executionScope == null ? normalizeScope(task.getScopeKey()) : executionScope; + } + + private HarnessTaskSpec withScope(HarnessTaskSpec spec, String scopeKey) { + String normalizedScope = normalizeScope(scopeKey); + if (safeEquals(normalizedScope, normalizeScope(spec.getScopeKey()))) { + return spec; + } + return spec.toBuilder().scopeKey(normalizedScope).build(); + } + + private String resolveEvidenceReferenceScope(HarnessState state, + List evidenceIds, + String currentScope, + boolean scopeAnchored, + String mismatchMessage) { + String resolvedScope = normalizeScope(currentScope); + boolean scopeBound = scopeAnchored || resolvedScope != null; + for (String evidenceId : copyList(evidenceIds)) { + String id = requireText(evidenceId, "evidence id"); + EvidenceRecord evidence = state.getEvidence().get(id); + if (evidence == null) { + throw new HarnessValidationException("evidence not found: " + id); + } + String evidenceScope = normalizeScope(evidence.getScopeKey()); + if (!scopeBound) { + resolvedScope = evidenceScope; + scopeBound = true; + } else { + assertSameScope(resolvedScope, evidenceScope, mismatchMessage); + } + } + return resolvedScope; + } + + private String resolveFactReferenceScope(HarnessState state, + List factIds, + String currentScope, + boolean scopeAnchored, + String mismatchMessage) { + String resolvedScope = normalizeScope(currentScope); + boolean scopeBound = scopeAnchored || resolvedScope != null; + for (String factId : copyList(factIds)) { + String id = requireText(factId, "fact id"); + FactRecord fact = state.getFacts().get(id); + if (fact == null) { + throw new HarnessValidationException("fact not found: " + id); + } + String factScope = normalizeScope(fact.getScopeKey()); + if (!scopeBound) { + resolvedScope = factScope; + scopeBound = true; + } else { + assertSameScope(resolvedScope, factScope, mismatchMessage); + } + } + return resolvedScope; + } + + private String relatedScope(HarnessState state, + String explicitScope, + String entityId, + EntityKind entityKind, + String mismatchMessage) { + String scope = normalizeScope(explicitScope); + if (entityId == null || entityId.trim().isEmpty()) { + return scope; + } + if (!entityExists(state, entityKind, entityId)) { + throw new HarnessValidationException(entityKind + " not found: " + entityId); + } + String entityScope = entityScope(state, entityKind, entityId); + if (scope != null) { + assertSameScope(scope, entityScope, mismatchMessage); + } + return scope == null ? entityScope : scope; + } + + private String entityScope(HarnessState state, EntityKind kind, String id) { + if (state == null || kind == null || id == null) { + return null; + } + switch (kind) { + case TASK: + TaskRecord task = state.getTasks().get(id); + return task == null ? null : normalizeScope(task.getScopeKey()); + case FACT: + FactRecord fact = state.getFacts().get(id); + return fact == null ? null : normalizeScope(fact.getScopeKey()); + case DECISION: + DecisionRecord decision = state.getDecisions().get(id); + return decision == null ? null : normalizeScope(decision.getScopeKey()); + case EXECUTION: + ExecutionRecord execution = state.getExecutions().get(id); + return execution == null ? null : normalizeScope(execution.getScopeKey()); + case EVIDENCE: + EvidenceRecord evidence = state.getEvidence().get(id); + return evidence == null ? null : normalizeScope(evidence.getScopeKey()); + default: + return null; + } + } + + private boolean entityExists(HarnessState state, EntityKind kind, String id) { + switch (kind) { + case TASK: return state.getTasks().containsKey(id); + case FACT: return state.getFacts().containsKey(id); + case DECISION: return state.getDecisions().containsKey(id); + case EXECUTION: return state.getExecutions().containsKey(id); + case EVIDENCE: return state.getEvidence().containsKey(id); + case CHECKPOINT: return state.getCheckpoints().containsKey(id); + case WAIT: return state.getWaits().containsKey(id); + case REVIEW: return state.getReviews().containsKey(id); + case SUBMISSION: return state.getSubmissions().containsKey(id); + default: return false; + } + } + + private RelationRecord findRelation(HarnessState state, HarnessRelationSpec spec) { + for (RelationRecord relation : state.getRelations().values()) { + if (relation == null) continue; + if (relation.getType() == spec.getType() + && relation.getFromKind() == spec.getFromKind() + && relation.getToKind() == spec.getToKind() + && safeEquals(relation.getFromId(), spec.getFromId()) + && safeEquals(relation.getToId(), spec.getToId())) { + return relation; + } + } + return null; + } + + private boolean wouldCreateCycle(HarnessState state, RelationType type, String fromId, String toId) { + if (safeEquals(fromId, toId)) return true; + Set visited = new LinkedHashSet(); + return reaches(state, type, toId, fromId, visited); + } + + private boolean reaches(HarnessState state, RelationType type, String current, String target, Set visited) { + if (!visited.add(current)) return false; + for (RelationRecord relation : state.getRelations().values()) { + if (relation == null || relation.getType() != type || !safeEquals(relation.getFromId(), current)) continue; + if (safeEquals(relation.getToId(), target) || reaches(state, type, relation.getToId(), target, visited)) return true; + } + return false; + } + + private boolean dependenciesSatisfied(HarnessState state, String taskId) { + for (RelationRecord relation : state.getRelations().values()) { + if (relation == null || relation.getType() != RelationType.DEPENDS_ON + || !safeEquals(relation.getFromId(), taskId)) continue; + TaskRecord dependency = state.getTasks().get(relation.getToId()); + if (dependency == null || !TaskStatus.DONE.equals(dependency.getStatus())) return false; + } + return true; + } + + private boolean hasOutstandingExecution(HarnessState state, String taskId) { + return hasOutstandingExecution(state, taskId, null); + } + + private boolean hasOutstandingExecution(HarnessState state, + String taskId, + String excludedExecutionId) { + for (ExecutionRecord execution : state.getExecutions().values()) { + if (execution != null && safeEquals(taskId, execution.getTaskId()) + && !safeEquals(excludedExecutionId, execution.getExecutionId()) + && (ExecutionStatus.READY.equals(execution.getStatus()) + || ExecutionStatus.RUNNING.equals(execution.getStatus()) + || ExecutionStatus.WAITING.equals(execution.getStatus()))) return true; + } + return false; + } + + private List openWaits(HarnessState state, String executionId) { + List result = new ArrayList(); + if (state == null || executionId == null) { + return result; + } + for (WaitRecord wait : state.getWaits().values()) { + if (wait != null && safeEquals(executionId, wait.getExecutionId()) + && WaitStatus.OPEN.equals(wait.getStatus())) { + result.add(wait); + } + } + Collections.sort(result, (left, right) -> { + int time = Long.compare(left.getCreatedAtEpochMs(), right.getCreatedAtEpochMs()); + return time != 0 ? time : String.valueOf(left.getWaitId()) + .compareTo(String.valueOf(right.getWaitId())); + }); + return result; + } + + private WaitRecord firstOpenWait(HarnessState state, String executionId) { + List waits = openWaits(state, executionId); + return waits.isEmpty() ? null : waits.get(0); + } + + /** + * Cancels resumable activity when a Task is cancelled. A RUNNING + * Execution is left to its lease holder because its external side effect + * may already be in flight; persistExecutionOutcome will fence its normal + * success/continuation result and record cancellation instead. + */ + private void cancelTaskActivity(HarnessState state, + String taskId, + String reason, + HarnessActor actor) { + long currentTime = now(); + String cancellationReason = reason == null || reason.trim().isEmpty() + ? "task cancelled" : reason; + for (WaitRecord wait : state.getWaits().values()) { + if (wait == null || !safeEquals(taskId, wait.getTaskId()) + || !WaitStatus.OPEN.equals(wait.getStatus())) { + continue; + } + wait.setStatus(WaitStatus.CANCELLED); + wait.setResolvedAtEpochMs(currentTime); + Map payload = wait.getPayload() == null + ? new LinkedHashMap() : wait.getPayload(); + payload.put("cancellationReason", cancellationReason); + wait.setPayload(payload); + addEvent(state, "wait.cancelled", wait.getWaitId(), actor, + mapOf("taskId", taskId, "reason", cancellationReason)); + } + for (ToolInvocationRecord invocation : state.getToolInvocations().values()) { + if (invocation == null || !safeEquals(taskId, invocation.getTaskId()) + || (ToolInvocationStatus.STARTED != invocation.getStatus() + && ToolInvocationStatus.WAITING != invocation.getStatus())) { + continue; + } + invocation.setStatus(ToolInvocationStatus.CANCELLED); + invocation.setError(cancellationReason); + invocation.setUpdatedAtEpochMs(currentTime); + invocation.setVersion(invocation.getVersion() + 1L); + addEvent(state, "tool.invocation_cancelled", invocation.getInvocationId(), actor, + mapOf("taskId", taskId, "executionId", invocation.getExecutionId(), + "waitId", invocation.getWaitId(), "operationId", invocation.getOperationId(), + "reason", cancellationReason)); + } + for (ExecutionRecord execution : state.getExecutions().values()) { + if (execution == null || !safeEquals(taskId, execution.getTaskId()) + || (ExecutionStatus.READY != execution.getStatus() + && ExecutionStatus.WAITING != execution.getStatus())) { + continue; + } + execution.setStatus(ExecutionStatus.CANCELLED); + execution.setWaitId(null); + execution.setOperationId(null); + execution.setError(cancellationReason); + execution.setFinishedAtEpochMs(currentTime); + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + addEvent(state, "execution.cancelled", execution.getExecutionId(), actor, + mapOf("taskId", taskId, "reason", cancellationReason)); + } + } + + private boolean isRunnableStatus(TaskStatus status) { + return TaskStatus.PLANNED.equals(status) || TaskStatus.ACTIVE.equals(status); + } + + private void acquireSessionLease(HarnessState state, + ExecutionRecord execution, + String workerId, + String leaseId, + long fencingToken, + long acquiredAt, + long expiresAt) { + String sessionId = trimToNull(execution.getSessionId()); + if (sessionId == null) { + return; + } + SessionLeaseRecord current = state.getSessionLeases().get(sessionId); + if (current != null && !current.isExpired(acquiredAt)) { + if (sameSessionLease(current, execution, leaseId, fencingToken, workerId)) { + current.setExpiresAtEpochMs(Math.max(current.getExpiresAtEpochMs(), expiresAt)); + return; + } + throw new HarnessConflictException("session is already leased by execution " + + current.getExecutionId() + " for worker " + current.getWorkerId()); + } + if (current != null) { + ExecutionRecord previous = state.getExecutions().get(current.getExecutionId()); + if (previous != null && ExecutionStatus.RUNNING.equals(previous.getStatus()) + && sameSessionLease(current, previous, current.getLeaseId(), + current.getFencingToken(), current.getWorkerId())) { + markExecutionUnknown(state, previous, acquiredAt, + "session lease expired; side effects require reconciliation", + HarnessActor.worker(workerId)); + } else { + current.setReleasedAtEpochMs(acquiredAt); + } + } + SessionLeaseRecord replacement = SessionLeaseRecord.builder() + .sessionId(sessionId) + .executionId(execution.getExecutionId()) + .workerId(workerId) + .leaseId(leaseId) + .fencingToken(fencingToken) + .acquiredAtEpochMs(acquiredAt) + .expiresAtEpochMs(expiresAt) + .build(); + state.getSessionLeases().put(sessionId, replacement); + addEvent(state, "session.lease_acquired", sessionId, HarnessActor.worker(workerId), + mapOf("executionId", execution.getExecutionId(), "leaseId", leaseId, + "fencingToken", fencingToken)); + } + + /** + * Ensures a RUNNING execution still owns its session lease. A missing + * record can be reconstructed from the execution lease; an expired or + * differently-owned record fences the execution instead of allowing a + * session to be used concurrently. + */ + private boolean ensureSessionLeaseForRunning(HarnessState state, + ExecutionRecord execution, + LeaseRecord executionLease, + long currentTime) { + String sessionId = trimToNull(execution.getSessionId()); + if (sessionId == null) { + return true; + } + SessionLeaseRecord current = state.getSessionLeases().get(sessionId); + if (current == null) { + SessionLeaseRecord reconstructed = SessionLeaseRecord.builder() + .sessionId(sessionId) + .executionId(execution.getExecutionId()) + .workerId(execution.getWorkerId()) + .leaseId(executionLease.getLeaseId()) + .fencingToken(executionLease.getFencingToken()) + .acquiredAtEpochMs(executionLease.getAcquiredAtEpochMs()) + .expiresAtEpochMs(executionLease.getExpiresAtEpochMs()) + .build(); + state.getSessionLeases().put(sessionId, reconstructed); + addEvent(state, "session.lease_reconstructed", sessionId, + HarnessActor.worker(execution.getWorkerId()), + mapOf("executionId", execution.getExecutionId(), "leaseId", executionLease.getLeaseId())); + return true; + } + if (current.isExpired(currentTime)) { + markExecutionUnknown(state, execution, currentTime, + "session lease expired; side effects require reconciliation", + HarnessActor.worker(execution.getWorkerId())); + return false; + } + if (!sameSessionLease(current, execution, executionLease.getLeaseId(), + executionLease.getFencingToken(), execution.getWorkerId())) { + throw new HarnessConflictException("session is owned by another execution: " + sessionId); + } + current.setExpiresAtEpochMs(Math.max(current.getExpiresAtEpochMs(), + executionLease.getExpiresAtEpochMs())); + return true; + } + + private boolean refreshSessionLease(HarnessState state, + ExecutionRecord execution, + LeaseRecord executionLease, + long currentTime) { + String sessionId = trimToNull(execution.getSessionId()); + if (sessionId == null) { + return true; + } + SessionLeaseRecord current = state.getSessionLeases().get(sessionId); + if (current == null || current.isExpired(currentTime) + || !sameSessionLease(current, execution, executionLease.getLeaseId(), + executionLease.getFencingToken(), execution.getWorkerId())) { + markExecutionUnknown(state, execution, currentTime, + "session lease was lost during heartbeat; side effects require reconciliation", + HarnessActor.worker(execution.getWorkerId())); + return false; + } + current.setExpiresAtEpochMs(executionLease.getExpiresAtEpochMs()); + return true; + } + + private void releaseSessionLease(HarnessState state, + ExecutionRecord execution, + String leaseId, + long fencingToken, + String workerId, + long releasedAt) { + String sessionId = execution == null ? null : trimToNull(execution.getSessionId()); + if (sessionId == null) { + return; + } + SessionLeaseRecord current = state.getSessionLeases().get(sessionId); + if (current == null || !sameSessionLease(current, execution, leaseId, fencingToken, workerId)) { + return; + } + current.setReleasedAtEpochMs(releasedAt); + addEvent(state, "session.lease_released", sessionId, + HarnessActor.worker(workerId == null ? "unknown" : workerId), + mapOf("executionId", execution.getExecutionId(), "leaseId", leaseId, + "fencingToken", fencingToken)); + } + + private boolean sameSessionLease(SessionLeaseRecord sessionLease, + ExecutionRecord execution, + String leaseId, + long fencingToken, + String workerId) { + return sessionLease != null && execution != null + && safeEquals(sessionLease.getSessionId(), trimToNull(execution.getSessionId())) + && safeEquals(sessionLease.getExecutionId(), execution.getExecutionId()) + && safeEquals(sessionLease.getLeaseId(), leaseId) + && sessionLease.getFencingToken() == fencingToken + && safeEquals(sessionLease.getWorkerId(), workerId); + } + + /** Marks an execution and its resumable activity UNKNOWN in one mutation. */ + private void markExecutionUnknown(HarnessState state, + ExecutionRecord execution, + long currentTime, + String reason, + HarnessActor actor) { + if (execution == null || ExecutionStatus.UNKNOWN.equals(execution.getStatus())) { + return; + } + String executionId = execution.getExecutionId(); + String oldLeaseId = execution.getLeaseId(); + long oldFencingToken = execution.getFencingToken(); + String oldWorkerId = execution.getWorkerId(); + LeaseRecord lease = oldLeaseId == null ? null : state.getLeases().get(oldLeaseId); + if (lease != null && lease.getReleasedAtEpochMs() <= 0L) { + lease.setReleasedAtEpochMs(currentTime); + } + releaseSessionLease(state, execution, oldLeaseId, oldFencingToken, oldWorkerId, currentTime); + cancelOpenWaitsForExecution(state, executionId, reason, actor, currentTime); + markExecutionToolInvocationsUnknown(state, executionId, reason, actor, currentTime); + execution.setStatus(ExecutionStatus.UNKNOWN); + execution.setWaitId(null); + execution.setOperationId(null); + execution.setError(reason); + execution.setWorkerId(null); + execution.setLeaseId(null); + execution.setFencingToken(0L); + execution.setFinishedAtEpochMs(currentTime); + execution.setUpdatedAtEpochMs(currentTime); + execution.setVersion(execution.getVersion() + 1L); + TaskRecord task = execution.getTaskId() == null + ? null : state.getTasks().get(execution.getTaskId()); + if (task != null && !TaskStatus.DONE.equals(task.getStatus()) + && !TaskStatus.CANCELLED.equals(task.getStatus()) + && !TaskStatus.IN_REVIEW.equals(task.getStatus())) { + task.setStatus(TaskStatus.BLOCKED); + task.setBlockedReason(reason); + task.setUpdatedAtEpochMs(currentTime); + task.setVersion(task.getVersion() + 1L); + } + addEvent(state, "execution.unknown", executionId, actor, + mapOf("taskId", execution.getTaskId(), "workerId", oldWorkerId, + "leaseId", oldLeaseId, "fencingToken", oldFencingToken, + "reason", reason)); + } + + private void cancelOpenWaitsForExecution(HarnessState state, + String executionId, + String reason, + HarnessActor actor, + long currentTime) { + for (WaitRecord wait : state.getWaits().values()) { + if (wait == null || !safeEquals(executionId, wait.getExecutionId()) + || !WaitStatus.OPEN.equals(wait.getStatus())) { + continue; + } + wait.setStatus(WaitStatus.CANCELLED); + wait.setResolvedAtEpochMs(currentTime); + Map payload = copyMap(wait.getPayload()); + payload.put("cancellationReason", reason); + payload.put("executionUnknown", true); + wait.setPayload(payload); + addEvent(state, "wait.cancelled", wait.getWaitId(), actor, + mapOf("executionId", executionId, "reason", reason)); + } + } + + private void markExecutionToolInvocationsUnknown(HarnessState state, + String executionId, + String reason, + HarnessActor actor, + long currentTime) { + for (ToolInvocationRecord invocation : state.getToolInvocations().values()) { + if (invocation == null || !safeEquals(executionId, invocation.getExecutionId()) + || (ToolInvocationStatus.STARTED != invocation.getStatus() + && ToolInvocationStatus.WAITING != invocation.getStatus())) { + continue; + } + invocation.setStatus(ToolInvocationStatus.UNKNOWN); + invocation.setError(reason); + invocation.setUpdatedAtEpochMs(currentTime); + invocation.setVersion(invocation.getVersion() + 1L); + addEvent(state, "tool.invocation_unknown", invocation.getInvocationId(), actor, + mapOf("executionId", executionId, "waitId", invocation.getWaitId(), + "operationId", invocation.getOperationId(), "reason", reason)); + } + } + + private void assertSameToolInvocation(ToolInvocationRecord existing, + HarnessToolInvocationSpec requested, + String scopeKey) { + if (!safeEquals(existing.getExecutionId(), requested.getExecutionId()) + || !safeEquals(existing.getToolName(), requested.getToolName()) + || !safeEquals(existing.getCallId(), requested.getCallId()) + || !safeEquals(existing.getTaskId(), requested.getTaskId()) + || !safeEquals(existing.getSessionId(), requested.getSessionId()) + || !safeEquals(existing.getScopeKey(), scopeKey) + || !equivalentNullableArguments(existing.getArguments(), requested.getArguments())) { + throw new HarnessConflictException("tool invocation id is already bound to another call: " + + existing.getInvocationId()); + } + } + + private ToolInvocationRecord requireApprovalInvocation(HarnessState state, + String invocationId, + ExecutionRecord execution, + String toolName, + String callId, + String arguments) { + ToolInvocationRecord invocation = state.getToolInvocations().get(invocationId); + if (invocation == null) { + throw new HarnessValidationException("tool invocation not found: " + invocationId); + } + if (!safeEquals(invocation.getExecutionId(), execution.getExecutionId()) + || !safeEquals(invocation.getToolName(), toolName) + || !safeEquals(invocation.getCallId(), callId) + || !equivalentNullableArguments(invocation.getArguments(), arguments)) { + throw new HarnessConflictException("approval invocation does not match the requested tool call: " + + invocationId); + } + if (!ToolInvocationStatus.STARTED.equals(invocation.getStatus()) + && !ToolInvocationStatus.WAITING.equals(invocation.getStatus())) { + throw new HarnessConflictException("tool invocation cannot wait from status " + + invocation.getStatus() + ": " + invocationId); + } + return invocation; + } + + private void linkApprovalInvocation(ToolInvocationRecord invocation, + WaitRecord wait, + HarnessActor actor, + HarnessState state) { + if (invocation == null || wait == null) { + return; + } + if (ToolInvocationStatus.WAITING.equals(invocation.getStatus())) { + if (invocation.getWaitId() != null + && !safeEquals(invocation.getWaitId(), wait.getWaitId())) { + throw new HarnessConflictException("tool invocation approval wait changed: " + + invocation.getInvocationId()); + } + if (invocation.getWaitId() == null) { + invocation.setWaitId(wait.getWaitId()); + invocation.setUpdatedAtEpochMs(now()); + invocation.setVersion(invocation.getVersion() + 1L); + } + return; + } + if (!ToolInvocationStatus.STARTED.equals(invocation.getStatus())) { + throw new HarnessConflictException("tool invocation cannot wait from status " + + invocation.getStatus() + ": " + invocation.getInvocationId()); + } + invocation.setStatus(ToolInvocationStatus.WAITING); + invocation.setOperationId(null); + invocation.setWaitId(wait.getWaitId()); + invocation.setUpdatedAtEpochMs(now()); + invocation.setVersion(invocation.getVersion() + 1L); + addEvent(state, "tool.invocation_waiting", invocation.getInvocationId(), actor, + mapOf("operationId", null, "waitId", wait.getWaitId(), "reason", "approval")); + } + + private boolean approvedInvocationRetry(HarnessState state, + ToolInvocationRecord invocation, + HarnessToolInvocationSpec requested) { + return approvedInvocationRetry(state, invocation, requested.getToolName(), + requested.getCallId(), requested.getArguments()); + } + + private boolean approvedInvocationRetry(HarnessState state, + ToolInvocationRecord invocation, + String toolName, + String callId, + String arguments) { + String waitId = trimToNull(invocation.getWaitId()); + if (waitId == null) { + return false; + } + WaitRecord wait = state.getWaits().get(waitId); + if (wait == null || !WaitType.APPROVAL.equals(wait.getType()) + || !WaitStatus.DELIVERED.equals(wait.getStatus()) + || !safeEquals(wait.getExecutionId(), invocation.getExecutionId())) { + return false; + } + Map payload = wait.getPayload(); + if (payload == null + || !safeEquals(invocation.getInvocationId(), String.valueOf( + payload.get(AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID))) + || !safeEquals(toolName, String.valueOf(payload.get("toolName"))) + || !deliveryApproved(payload.get("delivery"))) { + return false; + } + return approvalMatches(payload, callId, arguments); + } + + private void assertSameToolInvocationForApprovedRetry(ToolInvocationRecord existing, + HarnessToolInvocationSpec requested, + String scopeKey) { + if (!safeEquals(existing.getExecutionId(), requested.getExecutionId()) + || !safeEquals(existing.getToolName(), requested.getToolName()) + || !safeEquals(existing.getTaskId(), requested.getTaskId()) + || !safeEquals(existing.getSessionId(), requested.getSessionId()) + || !safeEquals(existing.getScopeKey(), scopeKey) + || !equivalentNullableArguments(existing.getArguments(), requested.getArguments())) { + throw new HarnessConflictException("approved retry does not match the original tool invocation: " + + existing.getInvocationId()); + } + } + + private boolean equivalentNullableArguments(String left, String right) { + if (left == null || right == null) { + return safeEquals(left, right); + } + return equivalentArguments(left, right); + } + + private void recordQuarantinedCompletion(HarnessState state, + ToolInvocationRecord invocation, + ToolInvocationStatus status, + String operationId, + String waitId, + String output, + String error, + HarnessActor actor) { + String effectiveWaitId = waitId == null ? invocation.getWaitId() : waitId; + WaitRecord wait = effectiveWaitId == null ? null : state.getWaits().get(effectiveWaitId); + String effectiveOperationId = operationId == null ? invocation.getOperationId() : operationId; + addEvent(state, "async.completion_quarantined", invocation.getInvocationId(), actor, + mapOf("waitId", effectiveWaitId, + "operationId", effectiveOperationId, + "invocationId", invocation.getInvocationId(), + "waitStatus", wait == null ? null : wait.getStatus(), + "delivery", "quarantined", + "error", error, + "output", output, + "lateCompletion", true, + "sideEffectStatus", ToolInvocationStatus.SUCCEEDED.equals(status) + ? "possibly_applied" : "unknown")); + } + + private void requireTaskTransition(TaskStatus from, TaskStatus to) { + if (from == to) return; + boolean allowed = false; + if (TaskStatus.PLANNED.equals(from)) allowed = to == TaskStatus.ACTIVE || to == TaskStatus.BLOCKED || to == TaskStatus.CANCELLED; + if (TaskStatus.ACTIVE.equals(from)) allowed = to == TaskStatus.WAITING || to == TaskStatus.BLOCKED || to == TaskStatus.IN_REVIEW || to == TaskStatus.CANCELLED; + if (TaskStatus.WAITING.equals(from)) allowed = to == TaskStatus.ACTIVE || to == TaskStatus.BLOCKED || to == TaskStatus.CANCELLED; + if (TaskStatus.BLOCKED.equals(from)) allowed = to == TaskStatus.PLANNED || to == TaskStatus.ACTIVE || to == TaskStatus.CANCELLED; + if (TaskStatus.IN_REVIEW.equals(from)) allowed = to == TaskStatus.ACTIVE || to == TaskStatus.CANCELLED; + if (!allowed || TaskStatus.DONE.equals(from) || TaskStatus.CANCELLED.equals(from)) { + throw new HarnessConflictException("illegal task transition: " + from + " -> " + to); + } + } + + private void assertLease(HarnessState state, ExecutionRecord execution, String leaseId, + long fencingToken, String workerId) { + if (leaseId == null || execution.getLeaseId() == null || !leaseId.equals(execution.getLeaseId()) + || execution.getFencingToken() != fencingToken || workerId == null + || !workerId.equals(execution.getWorkerId())) { + throw new HarnessConflictException("stale execution lease or fencing token: " + execution.getExecutionId()); + } + LeaseRecord lease = state.getLeases().get(leaseId); + if (lease == null || lease.isExpired(now()) || lease.getFencingToken() != fencingToken + || !workerId.equals(lease.getWorkerId())) { + throw new HarnessConflictException("execution lease is expired or fenced: " + execution.getExecutionId()); + } + } + + private void assertSnapshotMatchesExecution(ExecutionRecord execution, + AgentSessionSnapshot snapshot) { + if (snapshot == null) { + return; + } + if (!safeEquals(trimToNull(execution.getSessionId()), trimToNull(snapshot.getSessionId()))) { + throw new HarnessConflictException("session snapshot does not belong to execution session: " + + execution.getExecutionId()); + } + String snapshotRunId = trimToNull(snapshot.getRunId()); + if (snapshotRunId != null + && !safeEquals(trimToNull(execution.getRunId()), snapshotRunId)) { + throw new HarnessConflictException("session snapshot run id does not match execution: " + + execution.getExecutionId()); + } + } + + /** + * A Session has one stable Agent run identity across independent Harness + * Executions. Reuse it when a new Execution targets a Session that already + * has a durable snapshot; otherwise create the first run identity. + */ + private String resolveExecutionRunId(HarnessState state, HarnessExecutionSpec spec) { + String requestedRunId = trimToNull(spec.getRunId()); + String sessionId = trimToNull(spec.getSessionId()); + AgentSessionSnapshot snapshot = sessionId == null || state == null + ? null : state.getSessions().get(sessionId); + String sessionRunId = snapshot == null ? null : trimToNull(snapshot.getRunId()); + if (requestedRunId != null) { + if (sessionRunId != null && !safeEquals(requestedRunId, sessionRunId)) { + throw new HarnessConflictException("execution run id does not match session snapshot: " + + sessionId); + } + return requestedRunId; + } + return sessionRunId == null ? valueOrGenerated(null, "run_") : sessionRunId; + } + + private boolean hasApprovedReview(HarnessState state, String submissionId) { + ReviewRecord latest = null; + for (ReviewRecord review : state.getReviews().values()) { + if (review == null || !safeEquals(submissionId, review.getSubmissionId())) { + continue; + } + if (latest == null || review.getCreatedAtEpochMs() >= latest.getCreatedAtEpochMs()) { + latest = review; + } + } + return latest != null && ReviewVerdict.APPROVED.equals(latest.getVerdict()); + } + + private void assertCurrentTaskExecution(TaskRecord task, String executionId) { + String currentExecutionId = trimToNull(task == null ? null : task.getLastExecutionId()); + if (currentExecutionId != null && !currentExecutionId.equals(executionId)) { + throw new HarnessConflictException("execution is no longer the current task execution: " + + executionId); + } + } + + boolean deliveryApproved(Object value) { + if (value instanceof Boolean) return ((Boolean) value).booleanValue(); + if (value instanceof Map) { + Map map = (Map) value; + Object approved = map.get("approved"); + if (approved instanceof Boolean) return ((Boolean) approved).booleanValue(); + Object decision = map.get("decision"); + if (decision != null) return isApprovalWord(String.valueOf(decision)); + } + return value != null && isApprovalWord(String.valueOf(value)); + } + + private boolean approvalMatches(Map payload, + String callId, + String arguments) { + String approvedCallId = trimToNull(payload == null ? null : stringValue(payload.get("callId"))); + String approvedArguments = trimToNull(payload == null ? null : stringValue(payload.get("arguments"))); + String requestedCallId = trimToNull(callId); + String requestedArguments = trimToNull(arguments); + if (approvedCallId == null && approvedArguments == null) { + return true; + } + if (approvedCallId != null && requestedCallId != null + && approvedCallId.equals(requestedCallId)) { + return approvedArguments == null || requestedArguments == null + || equivalentArguments(approvedArguments, requestedArguments); + } + return approvedArguments != null && requestedArguments != null + && equivalentArguments(approvedArguments, requestedArguments); + } + + private boolean equivalentArguments(String left, String right) { + if (left.equals(right)) { + return true; + } + try { + Object leftValue = JSON.parse(left); + Object rightValue = JSON.parse(right); + return leftValue == null ? rightValue == null : leftValue.equals(rightValue); + } catch (RuntimeException ignored) { + return false; + } + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + private boolean isApprovalWord(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(); + return "approve".equals(normalized) || "approved".equals(normalized) + || "allow".equals(normalized) || "allowed".equals(normalized) + || "yes".equals(normalized) || "true".equals(normalized); + } + + private int nextAttempt(HarnessState state, String taskId) { + int max = 0; + for (ExecutionRecord execution : state.getExecutions().values()) { + if (execution != null && safeEquals(taskId, execution.getTaskId())) max = Math.max(max, execution.getAttempt()); + } + return max + 1; + } + + private long nextFencingToken(HarnessState state) { + long max = 0L; + for (LeaseRecord lease : state.getLeases().values()) { + if (lease != null) max = Math.max(max, lease.getFencingToken()); + } + return max + 1L; + } + + private boolean isTerminalExecution(ExecutionStatus status) { + return status == ExecutionStatus.SUCCEEDED || status == ExecutionStatus.FAILED + || status == ExecutionStatus.UNKNOWN || status == ExecutionStatus.CANCELLED; + } + + private TaskRecord requireTask(HarnessState state, String taskId) { + TaskRecord task = state.getTasks().get(taskId); + if (task == null) throw new HarnessValidationException("task not found: " + taskId); + return task; + } + + private ExecutionRecord requireExecution(HarnessState state, String executionId) { + ExecutionRecord execution = state.getExecutions().get(executionId); + if (execution == null) throw new HarnessValidationException("execution not found: " + executionId); + return execution; + } + + private HarnessProvenance provenance(HarnessActor actor, String executionId, String sessionId, + String sourceType, String sourceId) { + return HarnessProvenance.builder() + .actor(copyActor(actor)) + .executionId(executionId) + .sessionId(sessionId) + .sourceType(sourceType) + .sourceId(sourceId) + .recordedAtEpochMs(now()) + .build(); + } + + private void addEvent(HarnessState state, String type, String entityId, + HarnessActor actor, Map payload) { + List events = state.getEvents(); + long sequence = 1L; + if (events != null && !events.isEmpty()) sequence = events.get(events.size() - 1).getSequence() + 1L; + if (events == null) { + events = new ArrayList(); + state.setEvents(events); + } + events.add(HarnessEventRecord.builder() + .sequence(sequence) + .type(type) + .entityId(entityId) + .actorId(actorKey(actor)) + .recordedAtEpochMs(now()) + .payload(copyMap(payload)) + .build()); + } + + private T write(final StateCommand command) { + if (command == null) throw new IllegalArgumentException("Harness command is required"); + final ValueHolder holder = new ValueHolder(); + store.update(new HarnessStateMutation() { + @Override + public HarnessState apply(HarnessState current) { + current.ensureCollections(); + holder.value = command.apply(current); + return current; + } + }); + return holder.value; + } + + private String idempotentId(HarnessState state, + String entityType, + String scopeKey, + String key) { + String normalizedKey = trimToNull(key); + if (normalizedKey == null) { + return null; + } + String normalizedScope = normalizeScope(scopeKey); + String namespacedId = state.getIdempotency().get( + idempotencyNamespace(entityType, normalizedScope, normalizedKey)); + if (isIdempotencyTarget(state, entityType, namespacedId) + && sameEntityScope(state, entityType, namespacedId, normalizedScope)) { + return namespacedId; + } + + // State written before scoped idempotency was introduced used the raw + // key. Read it only when the mapped entity has the exact requested + // type and scope; a legacy raw key must never cross either boundary. + String legacyId = state.getIdempotency().get(normalizedKey); + if (isIdempotencyTarget(state, entityType, legacyId) + && sameEntityScope(state, entityType, legacyId, normalizedScope)) { + return legacyId; + } + return null; + } + + private void rememberIdempotency(HarnessState state, + String entityType, + String scopeKey, + String key, + String id) { + String normalizedKey = trimToNull(key); + if (normalizedKey != null && id != null) { + state.getIdempotency().put(idempotencyNamespace(entityType, + normalizeScope(scopeKey), normalizedKey), id); + } + } + + private String idempotencyNamespace(String entityType, String scopeKey, String key) { + return "v2|" + idempotencySegment(entityType) + + "|" + idempotencySegment(normalizeScope(scopeKey)) + + "|" + idempotencySegment(key); + } + + private String idempotencySegment(String value) { + if (value == null) { + return "-"; + } + return value.length() + ":" + value; + } + + private boolean isIdempotencyTarget(HarnessState state, String entityType, String id) { + if (id == null) { + return false; + } + if (IDEMPOTENCY_TASK.equals(entityType)) { + return state.getTasks().containsKey(id); + } + if (IDEMPOTENCY_EXECUTION.equals(entityType)) { + return state.getExecutions().containsKey(id); + } + return false; + } + + private boolean sameEntityScope(HarnessState state, + String entityType, + String id, + String scopeKey) { + if (!isIdempotencyTarget(state, entityType, id)) { + return false; + } + return safeEquals(normalizeScope(scopeKey), entityScope(state, + IDEMPOTENCY_TASK.equals(entityType) ? EntityKind.TASK : EntityKind.EXECUTION, id)); + } + + private String valueOrGenerated(String value, String prefix) { + String normalized = trimToNull(value); + return normalized == null ? prefix + UUID.randomUUID().toString().replace("-", "") : normalized; + } + + private String requireText(String value, String label) { + String normalized = trimToNull(value); + if (normalized == null) throw new HarnessValidationException(label + " is required"); + return normalized; + } + + private String trimToNull(String value) { + if (value == null) return null; + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + private String normalizeScope(String value) { + return trimToNull(value); + } + + private void assertCompatibleScope(String left, + String right, + String message) { + String normalizedLeft = normalizeScope(left); + String normalizedRight = normalizeScope(right); + if (normalizedLeft != null && normalizedRight != null + && !normalizedLeft.equals(normalizedRight)) { + throw new HarnessConflictException(message + ": " + + normalizedLeft + " != " + normalizedRight); + } + } + + private void assertSameScope(String left, String right, String message) { + String normalizedLeft = normalizeScope(left); + String normalizedRight = normalizeScope(right); + if (!safeEquals(normalizedLeft, normalizedRight)) { + throw new HarnessConflictException(message + ": " + + String.valueOf(normalizedLeft) + " != " + + String.valueOf(normalizedRight)); + } + } + + private boolean visibleScope(String entityScope, String requestedScope) { + String scope = normalizeScope(requestedScope); + return scope == null || (normalizeScope(entityScope) != null + && scope.equals(normalizeScope(entityScope))); + } + + private boolean visibleRelationScope(RelationRecord relation, String requestedScope) { + if (relation == null) { + return false; + } + String scope = normalizeScope(requestedScope); + if (scope == null) { + return true; + } + return visibleScope(relation.getScopeKey(), scope); + } + + private long now() { + return System.currentTimeMillis(); + } + + private String actorKey(HarnessActor actor) { + HarnessActor effective = normalizeActor(actor, defaultActor); + return effective.getKind() + ":" + effective.getId(); + } + + private HarnessActor normalizeActor(HarnessActor actor, HarnessActor fallback) { + HarnessActor source = actor == null ? fallback : actor; + if (source == null || trimToNull(source.getKind()) == null || trimToNull(source.getId()) == null) { + throw new HarnessValidationException("actor kind and id are required"); + } + return source; + } + + private HarnessActor copyActor(HarnessActor actor) { + return actor == null ? null : HarnessActor.builder().kind(actor.getKind()).id(actor.getId()).displayName(actor.getDisplayName()).build(); + } + + private Map copyMap(Map source) { + return source == null ? new LinkedHashMap() : new LinkedHashMap(source); + } + + private List copyList(List source) { + return source == null ? new ArrayList() : new ArrayList(source); + } + + private Map mapOf(Object... values) { + Map map = new LinkedHashMap(); + if (values == null) return map; + for (int i = 0; i + 1 < values.length; i += 2) map.put(String.valueOf(values[i]), values[i + 1]); + return map; + } + + private boolean safeEquals(Object left, Object right) { + return left == null ? right == null : left.equals(right); + } + + private boolean isGraphRelation(RelationType type) { + return RelationType.PARENT_OF.equals(type) || RelationType.DEPENDS_ON.equals(type); + } + + private static class ValueHolder { + private T value; + } + + private static class ConflictHolder { + private String message; + } + + private static class CompletionFailure { + private String reason; + } + + private interface StateCommand { + T apply(HarnessState state); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessConflictException.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessConflictException.java new file mode 100644 index 00000000..a2107af0 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessConflictException.java @@ -0,0 +1,9 @@ +package io.github.lnyocly.ai4j.harness; + +/** A concurrent command could not be applied without violating a lease or CAS rule. */ +public class HarnessConflictException extends HarnessStoreException { + + public HarnessConflictException(String message) { + super(message); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessContract.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessContract.java new file mode 100644 index 00000000..83ebac1a --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessContract.java @@ -0,0 +1,209 @@ +package io.github.lnyocly.ai4j.harness; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Application-owned governance rules. It describes what the runtime must + * enforce; it does not describe a fixed list of business Tasks. + */ +public interface HarnessContract { + + default boolean requiresTaskForTool(String toolName) { + return false; + } + + default boolean requiresApprovalForTool(String toolName) { + return false; + } + + default boolean acceptsAgentFact(HarnessFactSpec fact) { + return true; + } + + /** + * Controls whether a submitted Task needs an approved external review. + * Runtime execution limits are separate from this completion policy; a + * coding Harness may deliberately leave review optional while a regulated + * business workflow can require it. + */ + default boolean requiresApprovedReview(TaskRecord task, + SubmissionRecord submission) { + return true; + } + + default List completionGates() { + return Collections.emptyList(); + } + + default List evaluateCompletion(TaskRecord task, + SubmissionRecord submission, + HarnessState state) { + List gates = completionGates(); + if (gates == null || gates.isEmpty()) { + return Collections.singletonList(GateResult.pass("default")); + } + List results = new ArrayList(); + for (HarnessGate gate : gates) { + if (gate == null) { + continue; + } + GateResult result; + try { + result = gate.evaluate(task, submission, state); + } catch (RuntimeException error) { + result = GateResult.fail(gate.getName(), "gate threw: " + error.getMessage()); + } + results.add(result == null + ? GateResult.fail(gate.getName(), "gate returned no result") + : result); + } + return results; + } + + default boolean mayApprove(HarnessActor actor, SubmissionRecord submission) { + return actor != null && !actor.isAgent(); + } + + default boolean mayComplete(HarnessActor actor, SubmissionRecord submission) { + return actor != null && !actor.isAgent(); + } + + /** + * Allows a non-Agent actor to resolve an execution that became UNKNOWN + * after its lease expired. Reconciliation is intentionally separate from + * normal Agent execution because the external side effect may already + * have happened. + */ + default boolean mayReconcile(HarnessActor actor, + ExecutionRecord execution, + ExecutionStatus resolution) { + return actor != null && !actor.isAgent(); + } + + /** External side-effect lookup may reconcile a durable tool invocation. */ + default boolean mayReconcileToolInvocation(HarnessActor actor, + ToolInvocationRecord invocation, + ToolInvocationStatus resolution) { + return actor != null && !actor.isAgent(); + } + + static Builder builder() { + return new Builder(); + } + + /** Convenient immutable contract for the common set-based policy. */ + final class Builder { + private final Set taskRequiredTools = new LinkedHashSet(); + private final Set approvalRequiredTools = new LinkedHashSet(); + private final List completionGates = new ArrayList(); + private boolean approvedReviewRequired = true; + private boolean allowSystemApproval = true; + private boolean allowSystemCompletion = true; + private boolean allowSystemReconciliation = true; + + public Builder taskRequiredTool(String toolName) { + if (toolName != null && !toolName.trim().isEmpty()) { + taskRequiredTools.add(toolName.trim()); + } + return this; + } + + public Builder approvalRequiredTool(String toolName) { + if (toolName != null && !toolName.trim().isEmpty()) { + approvalRequiredTools.add(toolName.trim()); + } + return this; + } + + public Builder completionGate(HarnessGate gate) { + if (gate != null) { + completionGates.add(gate); + } + return this; + } + + public Builder requiresApprovedReview(boolean value) { + approvedReviewRequired = value; + return this; + } + + public Builder allowSystemApproval(boolean value) { + allowSystemApproval = value; + return this; + } + + public Builder allowSystemCompletion(boolean value) { + allowSystemCompletion = value; + return this; + } + + public Builder allowSystemReconciliation(boolean value) { + allowSystemReconciliation = value; + return this; + } + + public HarnessContract build() { + final Set taskTools = new LinkedHashSet(taskRequiredTools); + final Set approvalTools = new LinkedHashSet(approvalRequiredTools); + final List gates = new ArrayList(completionGates); + final boolean reviewRequired = approvedReviewRequired; + final boolean systemApproval = allowSystemApproval; + final boolean systemCompletion = allowSystemCompletion; + final boolean systemReconciliation = allowSystemReconciliation; + return new HarnessContract() { + @Override + public boolean requiresTaskForTool(String toolName) { + return toolName != null && taskTools.contains(toolName); + } + + @Override + public boolean requiresApprovalForTool(String toolName) { + return toolName != null && approvalTools.contains(toolName); + } + + @Override + public List completionGates() { + return new ArrayList(gates); + } + + @Override + public boolean requiresApprovedReview(TaskRecord task, + SubmissionRecord submission) { + return reviewRequired; + } + + @Override + public boolean mayApprove(HarnessActor actor, SubmissionRecord submission) { + return actor != null && (!actor.isAgent()) + && (systemApproval || !actor.isSystem()); + } + + @Override + public boolean mayComplete(HarnessActor actor, SubmissionRecord submission) { + return actor != null && (!actor.isAgent()) + && (systemCompletion || !actor.isSystem()); + } + + @Override + public boolean mayReconcile(HarnessActor actor, + ExecutionRecord execution, + ExecutionStatus resolution) { + return actor != null && (!actor.isAgent()) + && (systemReconciliation || !actor.isSystem()); + } + + @Override + public boolean mayReconcileToolInvocation(HarnessActor actor, + ToolInvocationRecord invocation, + ToolInvocationStatus resolution) { + return actor != null && (!actor.isAgent()) + && (systemReconciliation || !actor.isSystem()); + } + }; + } + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessDecisionSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessDecisionSpec.java new file mode 100644 index 00000000..fcc2f9b8 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessDecisionSpec.java @@ -0,0 +1,29 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessDecisionSpec { + + private String decisionId; + private String scopeKey; + private String taskId; + private String question; + private String chosenOption; + private String rationale; + + @Builder.Default + private List factIds = new ArrayList(); + + @Builder.Default + private List evidenceIds = new ArrayList(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessEventRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessEventRecord.java new file mode 100644 index 00000000..b32f46de --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessEventRecord.java @@ -0,0 +1,29 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessEventRecord { + + private long sequence; + private String type; + private String entityId; + private String actorId; + private long recordedAtEpochMs; + + @Builder.Default + private Map payload = new LinkedHashMap(); + + public HarnessEventRecord copy() { + return HarnessJson.copy(this, HarnessEventRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessEvidenceSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessEvidenceSpec.java new file mode 100644 index 00000000..847cf13c --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessEvidenceSpec.java @@ -0,0 +1,22 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessEvidenceSpec { + + private String evidenceId; + private String scopeKey; + private String taskId; + private String executionId; + private String kind; + private String location; + private String summary; + private String contentRef; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionAdapter.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionAdapter.java new file mode 100644 index 00000000..45ee363e --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionAdapter.java @@ -0,0 +1,35 @@ +package io.github.lnyocly.ai4j.harness; + +/** + * Adapter contract for runtimes whose resumable state is not an + * {@code AgentSessionSnapshot}. + * + *

An adapter owns opening/restoring its runtime, invoking one bounded + * slice, and exporting its state. The surrounding Harness still owns the + * durable Execution, lease, wait, checkpoint, dependency, review and gate + * lifecycle. Implementations should return expected runtime failures as a + * {@link HarnessAdapterExecution}; unexpected infrastructure failures may be + * thrown and will be classified by the outer Harness.

+ */ +public interface HarnessExecutionAdapter { + + /** Stable type used to partition adapter state in the Harness ledger. */ + String getAdapterType(); + + HarnessExecutionAdapterSession open(HarnessExecutionContext context, + HarnessRunBudget budget, + HarnessAdapterState previousState) throws Exception; + + /** + * Applies a host-delivered value to the adapter-owned pending state. A + * false replacement tells the Harness to pass the value as the next input + * when the wait represents a user or external event rather than a tool + * result. + */ + HarnessAdapterDelivery applyDelivery(HarnessAdapterState state, + WaitRecord wait, + Object input); + + default void close() { + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionAdapterSession.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionAdapterSession.java new file mode 100644 index 00000000..7dbe9ffb --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionAdapterSession.java @@ -0,0 +1,16 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.AgentRequest; + +/** One opened runtime session used for exactly one Harness execution slice. */ +public interface HarnessExecutionAdapterSession extends AutoCloseable { + + HarnessAdapterExecution run(AgentRequest request) throws Exception; + + /** Exports the complete adapter-owned recovery payload after a run. */ + HarnessAdapterState snapshot(); + + @Override + default void close() { + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionContext.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionContext.java new file mode 100644 index 00000000..31d53d33 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionContext.java @@ -0,0 +1,165 @@ +package io.github.lnyocly.ai4j.harness; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletionStage; + +/** Per-slice context shared by Harness management tools and the tool boundary. */ +public final class HarnessExecutionContext { + + public interface AsyncCompletionHandler { + /** Compatibility callback retained for integrations using the original context contract. */ + default void onCompletion(String waitId, Object result, Throwable error) { + } + + /** Callback carrying the durable operation and invocation identities. */ + default void onCompletion(String waitId, + String operationId, + String invocationId, + Object result, + Throwable error) { + onCompletion(waitId, result, error); + } + } + + private final HarnessCommandGateway gateway; + private final String executionId; + private final String sessionId; + private final String scopeKey; + private final String runId; + private final HarnessActor actor; + private final AsyncCompletionHandler asyncCompletionHandler; + private final List asyncCompletions = + new ArrayList(); + private volatile String taskId; + + public HarnessExecutionContext(HarnessCommandGateway gateway, + String executionId, + String taskId, + String sessionId, + String runId, + HarnessActor actor, + AsyncCompletionHandler asyncCompletionHandler) { + this(gateway, executionId, taskId, sessionId, null, runId, actor, asyncCompletionHandler); + } + + public HarnessExecutionContext(HarnessCommandGateway gateway, + String executionId, + String taskId, + String sessionId, + String scopeKey, + String runId, + HarnessActor actor, + AsyncCompletionHandler asyncCompletionHandler) { + if (gateway == null) { + throw new IllegalArgumentException("gateway is required"); + } + this.gateway = gateway; + this.executionId = executionId; + this.taskId = taskId; + this.sessionId = sessionId; + this.scopeKey = scopeKey; + this.runId = runId; + this.actor = actor; + this.asyncCompletionHandler = asyncCompletionHandler; + } + + public HarnessCommandGateway getGateway() { + return gateway; + } + + public String getExecutionId() { + return executionId; + } + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getSessionId() { + return sessionId; + } + + public String getScopeKey() { + return scopeKey; + } + + public String getRunId() { + return runId; + } + + public HarnessActor getActor() { + return actor; + } + + public AsyncCompletionHandler getAsyncCompletionHandler() { + return asyncCompletionHandler; + } + + /** + * Registers a CompletionStage without subscribing immediately. The + * AgentHarness activates registrations only after the execution outcome + * and checkpoint have been durably persisted, closing the completion race + * between an async tool and its Harness wait record. + */ + public synchronized void registerAsyncCompletion(String waitId, + String operationId, + CompletionStage completion) { + registerAsyncCompletion(waitId, operationId, null, completion); + } + + public synchronized void registerAsyncCompletion(String waitId, + String operationId, + String invocationId, + CompletionStage completion) { + if (completion == null || asyncCompletionHandler == null) { + return; + } + asyncCompletions.add(new AsyncCompletionRegistration(waitId, operationId, + invocationId, completion)); + } + + public synchronized List drainAsyncCompletions() { + List result = + new ArrayList(asyncCompletions); + asyncCompletions.clear(); + return result; + } + + public static final class AsyncCompletionRegistration { + private final String waitId; + private final String operationId; + private final String invocationId; + private final CompletionStage completion; + + private AsyncCompletionRegistration(String waitId, + String operationId, + String invocationId, + CompletionStage completion) { + this.waitId = waitId; + this.operationId = operationId; + this.invocationId = invocationId; + this.completion = completion; + } + + public String getWaitId() { + return waitId; + } + + public String getOperationId() { + return operationId; + } + + public String getInvocationId() { + return invocationId; + } + + public CompletionStage getCompletion() { + return completion; + } + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionOutcome.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionOutcome.java new file mode 100644 index 00000000..3967defa --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionOutcome.java @@ -0,0 +1,32 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.session.AgentSessionSnapshot; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Atomic durable outcome written after one Agent execution slice. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessExecutionOutcome { + + private String executionId; + private String leaseId; + private long fencingToken; + private ExecutionStatus status; + private String outputText; + private String error; + private String waitId; + private String operationId; + private String checkpointSummary; + private AgentSessionSnapshot sessionSnapshot; + + @Builder.Default + private Map checkpointState = new LinkedHashMap(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionSpec.java new file mode 100644 index 00000000..04646a4c --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessExecutionSpec.java @@ -0,0 +1,22 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Host input for creating one execution slice. Task and session are optional. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessExecutionSpec { + + private String executionId; + private String taskId; + private String scopeKey; + private String sessionId; + private String runId; + private String inputSummary; + private String idempotencyKey; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessFactSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessFactSpec.java new file mode 100644 index 00000000..483d6f67 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessFactSpec.java @@ -0,0 +1,31 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessFactSpec { + + private String factId; + private String scopeKey; + private String taskId; + private String statement; + private String source; + private String confidence; + + @Builder.Default + private List evidenceIds = new ArrayList(); + + @Builder.Default + private Map metadata = new LinkedHashMap(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessGate.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessGate.java new file mode 100644 index 00000000..92d0e079 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessGate.java @@ -0,0 +1,9 @@ +package io.github.lnyocly.ai4j.harness; + +/** A completion rule supplied by the application, not a business workflow engine. */ +public interface HarnessGate { + + String getName(); + + GateResult evaluate(TaskRecord task, SubmissionRecord submission, HarnessState state); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessJson.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessJson.java new file mode 100644 index 00000000..3903f96a --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessJson.java @@ -0,0 +1,16 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; + +final class HarnessJson { + + private HarnessJson() { + } + + static T copy(T value, Class type) { + if (value == null) { + return null; + } + return JSON.parseObject(JSON.toJSONString(value), type); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessManagementToolExecutor.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessManagementToolExecutor.java new file mode 100644 index 00000000..c2085379 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessManagementToolExecutor.java @@ -0,0 +1,490 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Function-call adapter for the durable Harness command surface. It is an + * Agent tool executor, but it never mutates state directly: every mutation is + * delegated to {@link HarnessCommandGateway}. + */ +public final class HarnessManagementToolExecutor implements AsyncToolExecutor { + + private final HarnessExecutionContext context; + + public HarnessManagementToolExecutor(HarnessExecutionContext context) { + if (context == null) { + throw new IllegalArgumentException("Harness execution context is required"); + } + this.context = context; + } + + @Override + public String execute(AgentToolCall call) throws Exception { + AgentToolExecution execution = start(call); + AgentToolResult result = execution == null ? null : execution.await(); + return result == null ? null : result.getOutput(); + } + + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (call == null || !HarnessToolNames.isManagementTool(call.getName())) { + throw new IllegalArgumentException("Unsupported Harness management tool: " + + (call == null ? null : call.getName())); + } + Map arguments = parseArguments(call.getArguments()); + if (HarnessToolNames.CONTEXT_GET.equals(call.getName())) { + return completed(call, contextView(arguments)); + } + if (HarnessToolNames.TASK_MANAGE.equals(call.getName())) { + return taskManage(call, arguments); + } + if (HarnessToolNames.FACT_RECORD.equals(call.getName())) { + return factManage(call, arguments); + } + if (HarnessToolNames.DECISION_PROPOSE.equals(call.getName())) { + return decisionManage(call, arguments); + } + if (HarnessToolNames.EVIDENCE_RECORD.equals(call.getName())) { + HarnessEvidenceSpec spec = HarnessEvidenceSpec.builder() + .evidenceId(string(arguments, "evidenceId")) + .scopeKey(scopeKey(arguments)) + .taskId(defaultTask(string(arguments, "taskId"))) + .executionId(string(arguments, "executionId")) + .kind(string(arguments, "kind")) + .location(string(arguments, "location")) + .summary(string(arguments, "summary")) + .contentRef(string(arguments, "contentRef")) + .build(); + return completed(call, context.getGateway().recordEvidence(spec, actor())); + } + if (HarnessToolNames.RELATION_MANAGE.equals(call.getName())) { + return relationManage(call, arguments); + } + if (HarnessToolNames.CONTROL_REQUEST.equals(call.getName())) { + return controlRequest(call, arguments); + } + if (HarnessToolNames.SUBMISSION_REQUEST.equals(call.getName())) { + return submissionRequest(call, arguments); + } + throw new IllegalArgumentException("Unsupported Harness management tool: " + call.getName()); + } + + private AgentToolExecution taskManage(AgentToolCall call, Map arguments) { + String operation = requiredString(arguments, "operation").toLowerCase(); + HarnessCommandGateway gateway = context.getGateway(); + if ("create".equals(operation)) { + String idempotencyKey = string(arguments, "idempotencyKey"); + if (idempotencyKey == null || idempotencyKey.trim().isEmpty()) { + idempotencyKey = derivedCreateIdempotencyKey(call); + } + HarnessTaskSpec spec = HarnessTaskSpec.builder() + .taskId(string(arguments, "taskId")) + .scopeKey(scopeKey(arguments)) + .title(string(arguments, "title")) + .goal(string(arguments, "goal")) + .plan(string(arguments, "plan")) + .parentTaskId(string(arguments, "parentTaskId")) + .idempotencyKey(idempotencyKey) + .tags(stringList(arguments.get("tags"))) + .metadata(objectMap(arguments.get("metadata"))) + .build(); + // A runtime-created first Task becomes the current Execution's + // Task only when that Execution was intentionally unbound. + if (context.getTaskId() == null && context.getExecutionId() != null) { + TaskRecord task = gateway.createTaskAndAttachExecution( + spec, context.getExecutionId(), actor()); + context.setTaskId(task.getTaskId()); + return completed(call, task); + } + TaskRecord task = gateway.createTask(spec, actor()); + return completed(call, task); + } + if ("split".equals(operation)) { + String parentId = defaultTask(string(arguments, "taskId")); + requireTaskInScope(parentId); + List children = taskSpecs(arguments.get("children")); + return completed(call, gateway.splitTask(parentId, children, actor())); + } + if ("update".equals(operation)) { + String taskId = requiredTask(arguments); + return completed(call, gateway.updateTask(taskId, + string(arguments, "title"), + string(arguments, "goal"), + string(arguments, "plan"), + arguments.containsKey("metadata") ? objectMap(arguments.get("metadata")) : null, + actor())); + } + if ("transition".equals(operation)) { + String taskId = requiredTask(arguments); + String status = requiredString(arguments, "status"); + return completed(call, gateway.transitionTask(taskId, + enumValue(TaskStatus.class, status), string(arguments, "reason"), actor())); + } + if ("add_dependency".equals(operation) || "add-dependency".equals(operation)) { + String taskId = requiredTask(arguments); + String dependencyTaskId = requireTaskInScope(requiredString(arguments, "dependencyTaskId")); + return completed(call, gateway.addDependency(taskId, dependencyTaskId, actor())); + } + if ("get".equals(operation)) { + return completed(call, gateway.getTask(requiredTask(arguments))); + } + if ("list".equals(operation)) { + return completed(call, gateway.listTasks(context.getScopeKey())); + } + if ("runnable".equals(operation) || "list_runnable".equals(operation)) { + return completed(call, gateway.listRunnableTasks(context.getScopeKey())); + } + throw new HarnessValidationException("unsupported task operation: " + operation); + } + + /** + * A provider retry can repeat the same management call without preserving + * the optional JSON idempotencyKey. The model's call id identifies that + * logical call; execution id keeps the identity local to one durable slice. + * Calls without either identity retain the explicit opt-in semantics and + * are allowed to create distinct Tasks. + */ + private String derivedCreateIdempotencyKey(AgentToolCall call) { + if (call == null || context.getExecutionId() == null + || call.getCallId() == null || call.getCallId().trim().isEmpty()) { + return null; + } + return "harness-management:task.create:" + context.getExecutionId().trim() + + ":" + call.getCallId().trim(); + } + + private AgentToolExecution factManage(AgentToolCall call, Map arguments) { + String operation = requiredString(arguments, "operation").toLowerCase(); + HarnessCommandGateway gateway = context.getGateway(); + if ("record".equals(operation)) { + HarnessFactSpec spec = HarnessFactSpec.builder() + .factId(string(arguments, "factId")) + .scopeKey(scopeKey(arguments)) + .taskId(defaultTask(string(arguments, "taskId"))) + .statement(string(arguments, "statement")) + .source(string(arguments, "source")) + .confidence(string(arguments, "confidence")) + .evidenceIds(stringList(arguments.get("evidenceIds"))) + .metadata(objectMap(arguments.get("metadata"))) + .build(); + return completed(call, gateway.recordFact(spec, actor())); + } + if ("invalidate".equals(operation)) { + String factId = requiredString(arguments, "factId"); + requireFactInScope(factId); + return completed(call, gateway.invalidateFact(factId, + string(arguments, "reason"), actor())); + } + throw new HarnessValidationException("unsupported fact operation: " + operation); + } + + private AgentToolExecution decisionManage(AgentToolCall call, Map arguments) { + String operation = requiredString(arguments, "operation").toLowerCase(); + HarnessCommandGateway gateway = context.getGateway(); + if ("propose".equals(operation)) { + HarnessDecisionSpec spec = HarnessDecisionSpec.builder() + .decisionId(string(arguments, "decisionId")) + .scopeKey(scopeKey(arguments)) + .taskId(defaultTask(string(arguments, "taskId"))) + .question(string(arguments, "question")) + .chosenOption(string(arguments, "chosenOption")) + .rationale(string(arguments, "rationale")) + .factIds(stringList(arguments.get("factIds"))) + .evidenceIds(stringList(arguments.get("evidenceIds"))) + .build(); + return completed(call, gateway.proposeDecision(spec, actor())); + } + if ("resolve".equals(operation)) { + String decisionId = requiredString(arguments, "decisionId"); + requireDecisionInScope(decisionId); + return completed(call, gateway.resolveDecision(decisionId, + enumValue(DecisionStatus.class, requiredString(arguments, "status")), + string(arguments, "rationale"), actor())); + } + throw new HarnessValidationException("unsupported decision operation: " + operation); + } + + private AgentToolExecution controlRequest(AgentToolCall call, Map arguments) { + String operation = requiredString(arguments, "operation").toLowerCase(); + HarnessCommandGateway gateway = context.getGateway(); + if ("checkpoint".equals(operation)) { + CheckpointRecord checkpoint = gateway.recordCheckpoint(context.getExecutionId(), + string(arguments, "summary"), objectMap(arguments.get("state")), actor()); + return completed(call, checkpoint); + } + if ("wait".equals(operation) || "approval".equals(operation)) { + WaitType type = "approval".equals(operation) + ? WaitType.APPROVAL + : enumValueOrDefault(WaitType.class, string(arguments, "type"), WaitType.EXTERNAL_EVENT); + Map payload = objectMap(arguments.get("payload")); + payload.put("toolName", call.getName()); + payload.put("callId", call.getCallId()); + payload.put("arguments", call.getArguments()); + if ("approval".equals(operation)) { + payload.put("approval", true); + payload.put("toolName", string(arguments, "toolName")); + payload.put("callId", string(arguments, "callId")); + payload.put("arguments", string(arguments, "arguments")); + } + WaitRecord wait = gateway.ensureWait(context.getExecutionId(), context.getTaskId(), + string(arguments, "waitId"), type, string(arguments, "operationId"), + string(arguments, "externalKey"), payload, actor()); + String output = "HARNESS_WAITING: " + JSON.toJSONString(wait); + return AgentToolExecution.of(AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.WAITING) + .waitId(wait.getWaitId()) + .operationId(wait.getOperationId()) + .build(), null); + } + throw new HarnessValidationException("unsupported control operation: " + operation); + } + + private AgentToolExecution submissionRequest(AgentToolCall call, Map arguments) { + String taskId = defaultTask(string(arguments, "taskId")); + HarnessSubmissionSpec spec = HarnessSubmissionSpec.builder() + .completionClaim(string(arguments, "completionClaim")) + .verificationNotes(string(arguments, "verificationNotes")) + .deliverables(stringList(arguments.get("deliverables"))) + .evidenceIds(stringList(arguments.get("evidenceIds"))) + .knownGaps(stringList(arguments.get("knownGaps"))) + .residualRisks(stringList(arguments.get("residualRisks"))) + .build(); + return completed(call, context.getGateway().submitTask(taskId, + string(arguments, "executionId") == null ? context.getExecutionId() : string(arguments, "executionId"), + spec, actor())); + } + + private Map contextView(Map arguments) { + HarnessCommandGateway gateway = context.getGateway(); + Map result = new LinkedHashMap(); + result.put("executionId", context.getExecutionId()); + result.put("sessionId", context.getSessionId()); + result.put("runId", context.getRunId()); + String focusedTaskId = string(arguments, "taskId"); + if (focusedTaskId == null) { + focusedTaskId = context.getTaskId(); + } + result.put("currentTask", focusedTaskId == null ? null + : gateway.getTaskInScope(focusedTaskId, context.getScopeKey())); + result.put("tasks", gateway.listTasks(context.getScopeKey())); + result.put("runnableTasks", gateway.listRunnableTasks(context.getScopeKey())); + result.put("openWaits", gateway.listOpenWaitsInScope(context.getScopeKey())); + result.put("facts", gateway.listFactsInScope(context.getScopeKey())); + result.put("decisions", gateway.listDecisionsInScope(context.getScopeKey())); + result.put("evidence", gateway.listEvidenceInScope(context.getScopeKey())); + result.put("relations", gateway.listRelationsInScope(context.getScopeKey())); + result.put("toolInvocations", gateway.listToolInvocationsInScope(context.getScopeKey())); + return result; + } + + private AgentToolExecution relationManage(AgentToolCall call, Map arguments) { + String operation = requiredString(arguments, "operation").toLowerCase(); + HarnessCommandGateway gateway = context.getGateway(); + if ("create".equals(operation) || "add".equals(operation)) { + HarnessRelationSpec spec = HarnessRelationSpec.builder() + .type(enumValue(RelationType.class, requiredString(arguments, "type"))) + .scopeKey(scopeKey(arguments)) + .fromKind(enumValue(EntityKind.class, requiredString(arguments, "fromKind"))) + .fromId(requiredString(arguments, "fromId")) + .toKind(enumValue(EntityKind.class, requiredString(arguments, "toKind"))) + .toId(requiredString(arguments, "toId")) + .metadata(objectMap(arguments.get("metadata"))) + .build(); + return completed(call, gateway.addRelation(spec, actor())); + } + if ("get".equals(operation)) { + return completed(call, gateway.getRelationInScope( + requiredString(arguments, "relationId"), context.getScopeKey())); + } + if ("list".equals(operation)) { + return completed(call, gateway.listRelationsInScope(context.getScopeKey())); + } + throw new HarnessValidationException("unsupported relation operation: " + operation); + } + + private AgentToolExecution completed(AgentToolCall call, Object value) { + AgentToolResult result = AgentToolResult.builder() + .name(call == null ? null : call.getName()) + .callId(call == null ? null : call.getCallId()) + .output(JSON.toJSONString(value)) + .status(AgentToolExecutionStatus.COMPLETED) + .build(); + return AgentToolExecution.completed(result); + } + + private HarnessActor actor() { + return context.getActor() == null ? HarnessActor.agent("ai4j-agent") : context.getActor(); + } + + private String requiredTask(Map arguments) { + return requireTaskInScope(requiredString(arguments, "taskId")); + } + + private String defaultTask(String value) { + String taskId = value == null ? context.getTaskId() : value; + return taskId == null ? null : requireTaskInScope(taskId); + } + + private String requireTaskInScope(String taskId) { + String normalized = requiredString(Collections.singletonMap("taskId", taskId), "taskId"); + if (context.getGateway().getTaskInScope(normalized, context.getScopeKey()) == null) { + throw new HarnessConflictException("task is outside the current Harness scope: " + normalized); + } + return normalized; + } + + private void requireFactInScope(String factId) { + for (FactRecord fact : context.getGateway().listFactsInScope(context.getScopeKey())) { + if (factId.equals(fact.getFactId())) return; + } + throw new HarnessConflictException("fact is outside the current Harness scope: " + factId); + } + + private void requireDecisionInScope(String decisionId) { + for (DecisionRecord decision : context.getGateway().listDecisionsInScope(context.getScopeKey())) { + if (decisionId.equals(decision.getDecisionId())) return; + } + throw new HarnessConflictException("decision is outside the current Harness scope: " + decisionId); + } + + private String scopeKey(Map arguments) { + String requested = string(arguments, "scopeKey"); + String current = context.getScopeKey(); + if (requested != null && current != null && !requested.trim().equals(current)) { + throw new HarnessConflictException("management tool cannot write outside the current Harness scope"); + } + return current == null ? (requested == null ? null : requested.trim()) : current; + } + + private Map parseArguments(String arguments) { + if (arguments == null || arguments.trim().isEmpty()) { + return new LinkedHashMap(); + } + try { + JSONObject object = JSON.parseObject(arguments); + return object == null ? new LinkedHashMap() + : new LinkedHashMap(object); + } catch (RuntimeException error) { + throw new HarnessValidationException("Harness tool arguments must be a JSON object: " + + error.getMessage()); + } + } + + private String string(Map values, String key) { + Object value = values == null ? null : values.get(key); + return value == null ? null : String.valueOf(value); + } + + private String requiredString(Map values, String key) { + String value = string(values, key); + if (value == null || value.trim().isEmpty()) { + throw new HarnessValidationException("Harness tool argument is required: " + key); + } + return value.trim(); + } + + private List stringList(Object raw) { + if (raw == null) { + return new ArrayList(); + } + if (raw instanceof String) { + String value = ((String) raw).trim(); + if (value.isEmpty()) { + return new ArrayList(); + } + try { + raw = JSON.parseArray(value); + } catch (RuntimeException ignored) { + return Collections.singletonList(value); + } + } + List result = new ArrayList(); + if (raw instanceof List) { + for (Object value : (List) raw) { + if (value != null && !String.valueOf(value).trim().isEmpty()) { + result.add(String.valueOf(value)); + } + } + return result; + } + result.add(String.valueOf(raw)); + return result; + } + + private Map objectMap(Object raw) { + if (raw == null) { + return new LinkedHashMap(); + } + if (raw instanceof String) { + String value = ((String) raw).trim(); + if (value.isEmpty()) { + return new LinkedHashMap(); + } + raw = JSON.parseObject(value); + } + if (!(raw instanceof Map)) { + throw new HarnessValidationException("Harness tool argument must be an object"); + } + Map result = new LinkedHashMap(); + for (Map.Entry entry : ((Map) raw).entrySet()) { + if (entry.getKey() != null) { + result.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return result; + } + + private List taskSpecs(Object raw) { + if (raw == null) { + return new ArrayList(); + } + if (raw instanceof String) { + raw = JSON.parseArray((String) raw); + } + if (!(raw instanceof List)) { + throw new HarnessValidationException("children must be an array"); + } + List result = new ArrayList(); + for (Object value : (List) raw) { + if (value == null) { + continue; + } + HarnessTaskSpec child = JSON.parseObject(JSON.toJSONString(value), HarnessTaskSpec.class); + String requestedScope = child == null ? null : child.getScopeKey(); + String currentScope = context.getScopeKey(); + if (requestedScope != null && currentScope != null + && !requestedScope.trim().equals(currentScope)) { + throw new HarnessConflictException( + "management tool cannot split a task outside the current Harness scope"); + } + if (child != null && currentScope != null) { + child = child.toBuilder().scopeKey(currentScope).build(); + } + result.add(child); + } + return result; + } + + private > E enumValue(Class type, String value) { + return Enum.valueOf(type, value.trim().toUpperCase()); + } + + private > E enumValueOrDefault(Class type, String value, E fallback) { + return value == null || value.trim().isEmpty() ? fallback : enumValue(type, value); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessPersistence.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessPersistence.java new file mode 100644 index 00000000..1e1fca70 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessPersistence.java @@ -0,0 +1,61 @@ +package io.github.lnyocly.ai4j.harness; + +import javax.sql.DataSource; +import java.nio.file.Path; + +/** Factory and lifecycle wrapper for a durable Harness store. */ +public final class HarnessPersistence implements AutoCloseable { + + private final HarnessStore store; + private final String harnessId; + + private HarnessPersistence(HarnessStore store, String harnessId) { + this.store = store; + this.harnessId = harnessId; + } + + public static HarnessPersistence file(Path directory) { + return file(FileHarnessConfig.builder().directory(directory).build()); + } + + public static HarnessPersistence file(FileHarnessConfig config) { + if (config == null || config.getDirectory() == null) { + throw new IllegalArgumentException("file harness directory is required"); + } + String harnessId = normalizeHarnessId(config.getHarnessId()); + return new HarnessPersistence(new FileHarnessStore(config.toBuilder().harnessId(harnessId).build()), harnessId); + } + + public static HarnessPersistence jdbc(DataSource dataSource, String harnessId) { + String normalized = normalizeHarnessId(harnessId); + return new HarnessPersistence(new JdbcHarnessStore(dataSource, normalized), normalized); + } + + public static HarnessPersistence jdbc(DataSource dataSource, + String harnessId, + int journalRetentionVersions) { + String normalized = normalizeHarnessId(harnessId); + return new HarnessPersistence(new JdbcHarnessStore(dataSource, normalized, + journalRetentionVersions), normalized); + } + + public HarnessStore getStore() { + return store; + } + + public String getHarnessId() { + return harnessId; + } + + @Override + public void close() { + store.close(); + } + + private static String normalizeHarnessId(String value) { + if (value == null || value.trim().isEmpty()) { + return "default"; + } + return value.trim(); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessPrompts.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessPrompts.java new file mode 100644 index 00000000..b7bbb2e2 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessPrompts.java @@ -0,0 +1,16 @@ +package io.github.lnyocly.ai4j.harness; + +/** Shared prompt fragment used by Agent and non-Agent Harness adapters. */ +public final class HarnessPrompts { + + private HarnessPrompts() { + } + + public static String instructions() { + return "This Agent runs inside a durable Harness. Manage work at runtime, not from a fixed predeclared task list. " + + "When work is substantial, call harness_context_get first; create or update Tasks with harness_task_manage, " + + "record durable facts, decisions and evidence, and use harness_control_request for waits or checkpoints. " + + "A Task submission is not completion: use harness_submission_request when the work is ready for external review. " + + "Never claim a Task is done merely because a slice ended. Continue from the latest durable context after a restart."; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessProvenance.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessProvenance.java new file mode 100644 index 00000000..2bded23e --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessProvenance.java @@ -0,0 +1,20 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessProvenance { + + private HarnessActor actor; + private String executionId; + private String sessionId; + private String sourceType; + private String sourceId; + private long recordedAtEpochMs; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRelationSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRelationSpec.java new file mode 100644 index 00000000..d5504d92 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRelationSpec.java @@ -0,0 +1,26 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessRelationSpec { + + private RelationType type; + private String scopeKey; + private EntityKind fromKind; + private String fromId; + private EntityKind toKind; + private String toId; + + @Builder.Default + private Map metadata = new LinkedHashMap(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunBudget.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunBudget.java new file mode 100644 index 00000000..ceab1e10 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunBudget.java @@ -0,0 +1,34 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Host-selected budget for one bounded slice or a runReady batch. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessRunBudget { + + @Builder.Default + private int maxExecutions = 1; + + /** 0 means retain the Agent's configured maxSteps. */ + @Builder.Default + private int maxSteps = 0; + + /** 0 means retain the Agent's configured wall clock timeout. */ + @Builder.Default + private long maxWallTimeMillis = 0L; + + /** -1 means retain the Agent's configured token budget. */ + @Builder.Default + private long maxTokenBudget = -1L; + + @Builder.Default + private long leaseDurationMillis = 60_000L; + + private String workerId; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunListener.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunListener.java new file mode 100644 index 00000000..631afad9 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunListener.java @@ -0,0 +1,7 @@ +package io.github.lnyocly.ai4j.harness; + +/** Receives results produced by automatic asynchronous wakeup continuation. */ +public interface HarnessRunListener { + + void onResult(HarnessRunResult result); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunRequest.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunRequest.java new file mode 100644 index 00000000..8daba03d --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunRequest.java @@ -0,0 +1,35 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.AgentRequest; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Convenient host-facing input; it does not force a business DTO or a Task. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessRunRequest { + + private String taskId; + private String executionId; + private String scopeKey; + private String sessionId; + private String idempotencyKey; + private Object input; + private AgentRequest agentRequest; + private HarnessRunBudget budget; + + public AgentRequest resolveAgentRequest() { + if (agentRequest != null) { + return agentRequest; + } + return AgentRequest.builder().input(input).build(); + } + + public static HarnessRunRequest input(Object value) { + return HarnessRunRequest.builder().input(value).build(); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunResult.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunResult.java new file mode 100644 index 00000000..f164ea7d --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunResult.java @@ -0,0 +1,27 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.AgentResult; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Result returned to the host after one bounded Harness execution slice. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessRunResult { + + private HarnessRunStatus status; + private ExecutionRecord execution; + private TaskRecord task; + private AgentResult agentResult; + + /** Runtime-specific result returned by a non-Agent execution adapter. */ + private Object adapterResult; + private String outputText; + private String waitId; + private String operationId; + private String error; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunStatus.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunStatus.java new file mode 100644 index 00000000..23082911 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessRunStatus.java @@ -0,0 +1,12 @@ +package io.github.lnyocly.ai4j.harness; + +public enum HarnessRunStatus { + COMPLETED, + CONTINUATION_REQUIRED, + WAITING, + BLOCKED, + IN_REVIEW, + FAILED, + UNKNOWN, + CANCELLED +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessState.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessState.java new file mode 100644 index 00000000..0e2994d0 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessState.java @@ -0,0 +1,106 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.session.AgentSessionSnapshot; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Serializable authoritative state for one logical Harness. Query projections + * can be rebuilt from this state and its journal; application business data is + * intentionally absent. + */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessState { + + @Builder.Default + private int schemaVersion = 1; + + private String harnessId; + private long version; + private long updatedAtEpochMs; + + @Builder.Default + private Map tasks = new LinkedHashMap(); + @Builder.Default + private Map facts = new LinkedHashMap(); + @Builder.Default + private Map decisions = new LinkedHashMap(); + @Builder.Default + private Map evidence = new LinkedHashMap(); + @Builder.Default + private Map executions = new LinkedHashMap(); + @Builder.Default + private Map relations = new LinkedHashMap(); + @Builder.Default + private Map checkpoints = new LinkedHashMap(); + @Builder.Default + private Map waits = new LinkedHashMap(); + @Builder.Default + private Map wakeups = new LinkedHashMap(); + @Builder.Default + private Map leases = new LinkedHashMap(); + @Builder.Default + private Map gates = new LinkedHashMap(); + @Builder.Default + private Map submissions = new LinkedHashMap(); + @Builder.Default + private Map reviews = new LinkedHashMap(); + @Builder.Default + private Map sessions = new LinkedHashMap(); + @Builder.Default + private Map sessionLeases = new LinkedHashMap(); + @Builder.Default + private Map toolInvocations = new LinkedHashMap(); + @Builder.Default + private Map idempotency = new LinkedHashMap(); + @Builder.Default + private List events = new ArrayList(); + + public static HarnessState empty(String harnessId) { + return HarnessState.builder() + .harnessId(harnessId) + .version(0L) + .updatedAtEpochMs(System.currentTimeMillis()) + .build(); + } + + public HarnessState copy() { + HarnessState copy = HarnessJson.copy(this, HarnessState.class); + if (copy == null) { + copy = HarnessState.empty(harnessId); + } + copy.ensureCollections(); + return copy; + } + + public void ensureCollections() { + if (tasks == null) tasks = new LinkedHashMap(); + if (facts == null) facts = new LinkedHashMap(); + if (decisions == null) decisions = new LinkedHashMap(); + if (evidence == null) evidence = new LinkedHashMap(); + if (executions == null) executions = new LinkedHashMap(); + if (relations == null) relations = new LinkedHashMap(); + if (checkpoints == null) checkpoints = new LinkedHashMap(); + if (waits == null) waits = new LinkedHashMap(); + if (wakeups == null) wakeups = new LinkedHashMap(); + if (leases == null) leases = new LinkedHashMap(); + if (gates == null) gates = new LinkedHashMap(); + if (submissions == null) submissions = new LinkedHashMap(); + if (reviews == null) reviews = new LinkedHashMap(); + if (sessions == null) sessions = new LinkedHashMap(); + if (sessionLeases == null) sessionLeases = new LinkedHashMap(); + if (toolInvocations == null) toolInvocations = new LinkedHashMap(); + if (idempotency == null) idempotency = new LinkedHashMap(); + if (events == null) events = new ArrayList(); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStateMutation.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStateMutation.java new file mode 100644 index 00000000..efa7b752 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStateMutation.java @@ -0,0 +1,7 @@ +package io.github.lnyocly.ai4j.harness; + +@FunctionalInterface +public interface HarnessStateMutation { + + HarnessState apply(HarnessState current); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStore.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStore.java new file mode 100644 index 00000000..293bdd8c --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStore.java @@ -0,0 +1,13 @@ +package io.github.lnyocly.ai4j.harness; + +/** Durable authoritative state store. Implementations must provide atomic update semantics. */ +public interface HarnessStore extends AutoCloseable { + + HarnessState load(); + + HarnessState update(HarnessStateMutation mutation); + + @Override + default void close() { + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStoreException.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStoreException.java new file mode 100644 index 00000000..4fbaecc7 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessStoreException.java @@ -0,0 +1,12 @@ +package io.github.lnyocly.ai4j.harness; + +public class HarnessStoreException extends RuntimeException { + + public HarnessStoreException(String message) { + super(message); + } + + public HarnessStoreException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessSubmissionSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessSubmissionSpec.java new file mode 100644 index 00000000..a299731c --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessSubmissionSpec.java @@ -0,0 +1,31 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessSubmissionSpec { + + private String completionClaim; + private String verificationNotes; + + @Builder.Default + private List deliverables = new ArrayList(); + + @Builder.Default + private List evidenceIds = new ArrayList(); + + @Builder.Default + private List knownGaps = new ArrayList(); + + @Builder.Default + private List residualRisks = new ArrayList(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessTaskSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessTaskSpec.java new file mode 100644 index 00000000..07147b26 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessTaskSpec.java @@ -0,0 +1,33 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Runtime-created task input. It is not a developer-maintained task template. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessTaskSpec { + + private String taskId; + private String scopeKey; + private String title; + private String goal; + private String plan; + private String parentTaskId; + private String idempotencyKey; + + @Builder.Default + private List tags = new ArrayList(); + + @Builder.Default + private Map metadata = new LinkedHashMap(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolExecutor.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolExecutor.java new file mode 100644 index 00000000..fcea3d25 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolExecutor.java @@ -0,0 +1,368 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import io.github.lnyocly.ai4j.agent.control.AgentHostInputException; +import io.github.lnyocly.ai4j.agent.permission.AgentApprovalRequiredException; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutors; +import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.CompletionStage; + +/** + * Mandatory execution boundary for a Harness-enabled Agent. It routes + * management calls to the command adapter and all other calls to the existing + * application executor, while making task requirements, approvals, waits and + * asynchronous completion durable. + */ +public final class HarnessToolExecutor implements AsyncToolExecutor { + + private final HarnessExecutionContext context; + private final ToolExecutor businessExecutor; + private final HarnessManagementToolExecutor managementExecutor; + + public HarnessToolExecutor(HarnessExecutionContext context, + ToolExecutor businessExecutor) { + if (context == null) { + throw new IllegalArgumentException("Harness execution context is required"); + } + this.context = context; + this.businessExecutor = businessExecutor; + this.managementExecutor = new HarnessManagementToolExecutor(context); + } + + public ToolExecutor getBusinessExecutor() { + return businessExecutor; + } + + @Override + public String execute(AgentToolCall call) throws Exception { + AgentToolExecution execution = start(call); + AgentToolResult result = execution == null ? null : execution.await(); + return result == null ? null : result.getOutput(); + } + + @Override + public AgentToolExecution start(AgentToolCall call) throws Exception { + if (call == null || call.getName() == null || call.getName().trim().isEmpty()) { + throw new IllegalArgumentException("tool call name is required"); + } + if (HarnessToolNames.isManagementTool(call.getName())) { + return managementExecutor.start(call); + } + String invocationId = invocationId(call); + if (businessExecutor == null) { + throw new IllegalStateException("business tool executor is required for: " + call.getName()); + } + + if (requiresTask(call) && context.getTaskId() == null) { + return failed(call, "HARNESS_TASK_REQUIRED: create or select a Task before calling " + call.getName()); + } + + boolean approvalGranted = context.getGateway().isApprovalGranted( + context.getExecutionId(), call.getName(), call.getCallId(), call.getArguments()); + ToolInvocationRecord existing = context.getGateway().getToolInvocationInScope( + invocationId, context.getScopeKey()); + if (existing == null && approvalGranted) { + ToolInvocationRecord approvedRetry = context.getGateway().findApprovedWaitingToolInvocation( + context.getExecutionId(), context.getScopeKey(), call.getName(), + call.getCallId(), call.getArguments()); + if (approvedRetry != null) { + invocationId = approvedRetry.getInvocationId(); + existing = approvedRetry; + } + } + if (existing != null && !(ToolInvocationStatus.WAITING.equals(existing.getStatus()) + && approvalGranted)) { + return existingResult(call, existing); + } + + AgentToolCall effectiveCall = call; + if (isApprovalRequired(call) && !approvalGranted) { + return approvalWait(call); + } + if (approvalGranted) { + effectiveCall = copyWithApproval(effectiveCall); + } + effectiveCall = copyWithMetadata(effectiveCall, + AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID, invocationId); + + ToolInvocationReservation reservation = context.getGateway().reserveToolInvocation( + HarnessToolInvocationSpec.builder() + .invocationId(invocationId) + .executionId(context.getExecutionId()) + .taskId(context.getTaskId()) + .sessionId(context.getSessionId()) + .scopeKey(context.getScopeKey()) + .toolName(call.getName()) + .callId(call.getCallId()) + .arguments(call.getArguments()) + .build(), context.getActor()); + ToolInvocationRecord started = reservation.getInvocation(); + if (!reservation.isCreated()) { + return existingResult(call, started); + } + if (!ToolInvocationStatus.STARTED.equals(started.getStatus())) { + return existingResult(call, started); + } + + try { + AgentToolExecution execution = AsyncToolExecutors.start(businessExecutor, effectiveCall); + if (execution == null) { + context.getGateway().completeToolInvocation(invocationId, + ToolInvocationStatus.SUCCEEDED, null, null, null, null, context.getActor()); + return completed(call, null); + } + AgentToolResult initial = normalize(call, execution.isPending() + ? execution.getInitialResult() : execution.await()); + if (!initial.isWaiting()) { + context.getGateway().completeToolInvocation(invocationId, + ledgerStatus(initial), initial.getOperationId(), initial.getWaitId(), + initial.getOutput(), initial.getError(), context.getActor()); + return AgentToolExecution.completed(initial); + } + + WaitRecord wait = ensureToolWait(effectiveCall, initial, WaitType.ASYNC_OPERATION); + initial.setWaitId(wait.getWaitId()); + if (initial.getOperationId() == null) { + initial.setOperationId(wait.getOperationId()); + } + context.getGateway().markToolInvocationWaiting(invocationId, + initial.getOperationId(), wait.getWaitId(), context.getActor()); + CompletionStage completion = execution.getCompletion(); + context.registerAsyncCompletion(wait.getWaitId(), wait.getOperationId(), invocationId, completion); + return AgentToolExecution.of(initial, completion); + } catch (AgentApprovalRequiredException approval) { + return approvalWait(call, approval, invocationId); + } catch (AgentHostInputException input) { + return inputWait(effectiveCall, input, invocationId); + } catch (Exception failure) { + try { + context.getGateway().completeToolInvocation(invocationId, + ToolInvocationStatus.UNKNOWN, null, null, null, + failure.getMessage() == null ? failure.toString() : failure.getMessage(), + context.getActor()); + } catch (RuntimeException ignored) { + // The original tool failure remains visible to the Agent; a + // later reconciliation can inspect the durable STARTED entry. + } + throw failure; + } + } + + private boolean requiresTask(AgentToolCall call) { + return context.getGateway().getContract().requiresTaskForTool(call.getName()); + } + + private boolean isApprovalRequired(AgentToolCall call) { + return context.getGateway().getContract().requiresApprovalForTool(call.getName()); + } + + private AgentToolExecution approvalWait(AgentToolCall call) { + return approvalWait(call, null); + } + + private AgentToolExecution approvalWait(AgentToolCall call, + AgentApprovalRequiredException cause) { + return approvalWait(call, cause, null); + } + + private AgentToolExecution approvalWait(AgentToolCall call, + AgentApprovalRequiredException cause, + String invocationId) { + Map payload = new LinkedHashMap(); + payload.put("toolName", call.getName()); + payload.put("callId", call.getCallId()); + payload.put("arguments", call.getArguments()); + payload.put("approval", true); + if (cause != null && cause.getDecision() != null) { + payload.put("reason", cause.getDecision().getReason()); + } + WaitRecord wait = context.getGateway().requestApproval(context.getExecutionId(), + context.getTaskId(), call.getName(), call.getCallId(), call.getArguments(), + invocationId, context.getActor()); + String output = "HARNESS_APPROVAL_REQUIRED: " + JSON.toJSONString(payload) + + "; waitId=" + wait.getWaitId(); + AgentToolResult result = AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.WAITING) + .waitId(wait.getWaitId()) + .build(); + return AgentToolExecution.of(result, null); + } + + private AgentToolExecution inputWait(AgentToolCall call, + AgentHostInputException cause, + String invocationId) { + WaitRecord wait = ensureToolWait(call, null, WaitType.USER_INPUT); + context.getGateway().markToolInvocationWaiting(invocationId, null, + wait.getWaitId(), context.getActor()); + String output = "HARNESS_USER_INPUT_REQUIRED: " + JSON.toJSONString(cause.getRequest()) + + "; waitId=" + wait.getWaitId(); + AgentToolResult result = AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.WAITING) + .waitId(wait.getWaitId()) + .build(); + return AgentToolExecution.of(result, null); + } + + private WaitRecord ensureToolWait(AgentToolCall call, + AgentToolResult initial, + WaitType type) { + Map payload = new LinkedHashMap(); + payload.put("toolName", call.getName()); + payload.put("callId", call.getCallId()); + payload.put("arguments", call.getArguments()); + Object invocationId = call.getMetadata() == null ? null + : call.getMetadata().get(AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID); + if (invocationId != null && !String.valueOf(invocationId).trim().isEmpty()) { + payload.put(AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID, + String.valueOf(invocationId)); + } + if (initial != null) { + payload.put("initialOutput", initial.getOutput()); + payload.put("operationId", initial.getOperationId()); + } + if (call.getMetadata() != null) { + Object parentCallId = call.getMetadata().get(AgentToolCall.METADATA_KEY_PARENT_CALL_ID); + if (parentCallId != null && !String.valueOf(parentCallId).trim().isEmpty()) { + payload.put(AgentToolCall.METADATA_KEY_PARENT_CALL_ID, String.valueOf(parentCallId)); + } + } + return context.getGateway().ensureWait(context.getExecutionId(), context.getTaskId(), + initial == null ? null : initial.getWaitId(), type, + initial == null ? null : initial.getOperationId(), null, payload, context.getActor()); + } + + private AgentToolCall copyWithApproval(AgentToolCall source) { + Map metadata = source.getMetadata() == null + ? new LinkedHashMap() + : new LinkedHashMap(source.getMetadata()); + metadata.put(AgentToolCall.METADATA_KEY_HARNESS_APPROVAL_GRANTED, Boolean.TRUE); + return AgentToolCall.builder() + .name(source.getName()) + .arguments(source.getArguments()) + .callId(source.getCallId()) + .type(source.getType()) + .metadata(metadata) + .build(); + } + + private AgentToolCall copyWithMetadata(AgentToolCall source, String key, Object value) { + Map metadata = source.getMetadata() == null + ? new LinkedHashMap() + : new LinkedHashMap(source.getMetadata()); + metadata.put(key, value); + return AgentToolCall.builder() + .name(source.getName()) + .arguments(source.getArguments()) + .callId(source.getCallId()) + .type(source.getType()) + .metadata(metadata) + .build(); + } + + private String invocationId(AgentToolCall call) { + Object explicit = call.getMetadata() == null ? null + : call.getMetadata().get(AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID); + if (explicit != null && !String.valueOf(explicit).trim().isEmpty()) { + return String.valueOf(explicit).trim(); + } + String seed = String.valueOf(context.getExecutionId()) + "|" + + String.valueOf(call.getName()) + "|" + + String.valueOf(call.getCallId()) + "|" + + String.valueOf(call.getArguments()); + return "toolinv_" + UUID.nameUUIDFromBytes(seed.getBytes(StandardCharsets.UTF_8)) + .toString().replace("-", ""); + } + + private AgentToolExecution existingResult(AgentToolCall call, + ToolInvocationRecord invocation) { + AgentToolExecutionStatus status; + String output; + if (ToolInvocationStatus.SUCCEEDED.equals(invocation.getStatus())) { + status = AgentToolExecutionStatus.COMPLETED; + output = invocation.getOutput(); + } else if (ToolInvocationStatus.WAITING.equals(invocation.getStatus())) { + status = AgentToolExecutionStatus.WAITING; + output = "HARNESS_TOOL_WAITING: invocationId=" + invocation.getInvocationId() + + "; waitId=" + invocation.getWaitId(); + } else if (ToolInvocationStatus.CANCELLED.equals(invocation.getStatus())) { + status = AgentToolExecutionStatus.FAILED; + output = "HARNESS_TOOL_CANCELLED: invocationId=" + invocation.getInvocationId(); + } else if (ToolInvocationStatus.FAILED.equals(invocation.getStatus())) { + status = AgentToolExecutionStatus.FAILED; + output = invocation.getOutput() == null ? invocation.getError() : invocation.getOutput(); + } else { + status = AgentToolExecutionStatus.UNKNOWN; + output = "HARNESS_TOOL_RECONCILIATION_REQUIRED: invocationId=" + + invocation.getInvocationId() + "; status=" + invocation.getStatus(); + } + return AgentToolExecution.of(AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(output) + .error(invocation.getError()) + .status(status) + .waitId(invocation.getWaitId()) + .operationId(invocation.getOperationId()) + .build(), null); + } + + private ToolInvocationStatus ledgerStatus(AgentToolResult result) { + if (result == null) { + return ToolInvocationStatus.SUCCEEDED; + } + if (AgentToolExecutionStatus.UNKNOWN.equals(result.getStatus())) { + return ToolInvocationStatus.UNKNOWN; + } + return result.isFailed() ? ToolInvocationStatus.FAILED : ToolInvocationStatus.SUCCEEDED; + } + + private AgentToolExecution completed(AgentToolCall call, String output) { + return AgentToolExecution.completed(AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + } + + private AgentToolExecution failed(AgentToolCall call, String output) { + return AgentToolExecution.completed(AgentToolResult.builder() + .name(call.getName()) + .callId(call.getCallId()) + .output(output) + .status(AgentToolExecutionStatus.FAILED) + .ok(Boolean.FALSE) + .error(output) + .build()); + } + + private AgentToolResult normalize(AgentToolCall call, AgentToolResult source) { + AgentToolResult result = source == null ? new AgentToolResult() : source; + if (result.getName() == null) { + result.setName(call.getName()); + } + if (result.getCallId() == null) { + result.setCallId(call.getCallId()); + } + if (result.getStatus() == null) { + result.setStatus(AgentToolExecutionStatus.COMPLETED); + } + return result; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolInterceptor.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolInterceptor.java new file mode 100644 index 00000000..4757e8b8 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolInterceptor.java @@ -0,0 +1,44 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.AgentContext; +import io.github.lnyocly.ai4j.agent.interceptor.ToolCallDecision; +import io.github.lnyocly.ai4j.agent.interceptor.ToolInterceptor; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; + +/** + * Keeps application interceptors active while ensuring Harness management + * commands can reach the Gateway. Business-tool enforcement lives in + * {@link HarnessToolExecutor}, so this interceptor is not the security boundary. + */ +public final class HarnessToolInterceptor implements ToolInterceptor { + + private final ToolInterceptor delegate; + + public HarnessToolInterceptor(ToolInterceptor delegate) { + this.delegate = delegate; + } + + @Override + public ToolCallDecision beforeToolCall(AgentToolCall call, AgentContext context) { + if (HarnessToolNames.isManagementTool(call == null ? null : call.getName())) { + return ToolCallDecision.allow(); + } + if (delegate == null) { + return ToolCallDecision.allow(); + } + ToolCallDecision decision = delegate.beforeToolCall(call, context); + return decision == null ? ToolCallDecision.allow() : decision; + } + + @Override + public ToolCallDecision afterToolCall(AgentToolCall call, String output, AgentContext context) { + if (HarnessToolNames.isManagementTool(call == null ? null : call.getName())) { + return ToolCallDecision.allow(); + } + if (delegate == null) { + return ToolCallDecision.allow(); + } + ToolCallDecision decision = delegate.afterToolCall(call, output, context); + return decision == null ? ToolCallDecision.allow() : decision; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolInvocationSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolInvocationSpec.java new file mode 100644 index 00000000..d3c10435 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolInvocationSpec.java @@ -0,0 +1,23 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Identity and ownership data for one durable business-tool invocation. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessToolInvocationSpec { + + private String invocationId; + private String executionId; + private String taskId; + private String sessionId; + private String scopeKey; + private String toolName; + private String callId; + private String arguments; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolNames.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolNames.java new file mode 100644 index 00000000..8dc6f2b0 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolNames.java @@ -0,0 +1,47 @@ +package io.github.lnyocly.ai4j.harness; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** Reserved Function Call names exposed by the Harness runtime. */ +public final class HarnessToolNames { + + public static final String CONTEXT_GET = "harness_context_get"; + public static final String TASK_MANAGE = "harness_task_manage"; + public static final String FACT_RECORD = "harness_fact_record"; + public static final String DECISION_PROPOSE = "harness_decision_propose"; + public static final String EVIDENCE_RECORD = "harness_evidence_record"; + public static final String RELATION_MANAGE = "harness_relation_manage"; + public static final String CONTROL_REQUEST = "harness_control_request"; + public static final String SUBMISSION_REQUEST = "harness_submission_request"; + + private static final List ORDERED = Collections.unmodifiableList(Arrays.asList( + CONTEXT_GET, + TASK_MANAGE, + FACT_RECORD, + DECISION_PROPOSE, + EVIDENCE_RECORD, + RELATION_MANAGE, + CONTROL_REQUEST, + SUBMISSION_REQUEST + )); + + private HarnessToolNames() { + } + + public static List all() { + return Collections.unmodifiableList(new ArrayList(ORDERED)); + } + + public static Set asSet() { + return Collections.unmodifiableSet(new LinkedHashSet(ORDERED)); + } + + public static boolean isManagementTool(String name) { + return name != null && asSet().contains(name); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolRegistry.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolRegistry.java new file mode 100644 index 00000000..54618f80 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessToolRegistry.java @@ -0,0 +1,211 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; +import io.github.lnyocly.ai4j.platform.openai.tool.Tool; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Adds the Harness management tools to an existing Agent tool registry. + * Business tools remain owned by the application and are returned unchanged. + */ +public final class HarnessToolRegistry implements AgentToolRegistry { + + private final AgentToolRegistry businessRegistry; + private final List managementTools; + + public HarnessToolRegistry(AgentToolRegistry businessRegistry) { + this.businessRegistry = businessRegistry; + this.managementTools = createManagementTools(); + assertNoReservedCollision(businessRegistry); + } + + public AgentToolRegistry getBusinessRegistry() { + return businessRegistry; + } + + @Override + public List getTools() { + List tools = new ArrayList(); + tools.addAll(managementTools); + if (businessRegistry != null && businessRegistry.getTools() != null) { + tools.addAll(businessRegistry.getTools()); + } + return tools; + } + + private void assertNoReservedCollision(AgentToolRegistry registry) { + if (registry == null || registry.getTools() == null) { + return; + } + for (Object item : registry.getTools()) { + if (!(item instanceof Tool)) { + continue; + } + Tool.Function function = ((Tool) item).getFunction(); + if (function != null && HarnessToolNames.isManagementTool(function.getName())) { + throw new IllegalArgumentException("Business tool uses reserved Harness name: " + + function.getName()); + } + } + } + + private List createManagementTools() { + List tools = new ArrayList(); + tools.add(tool(HarnessToolNames.CONTEXT_GET, + "Read the durable Harness context for this execution: current task, runnable tasks, waits, facts, decisions and evidence.", + properties( + property("taskId", "string", "Optional task to focus on.") + ), + Collections.emptyList())); + tools.add(tool(HarnessToolNames.TASK_MANAGE, + "Create, split, update, transition, list, or connect durable Tasks. Tasks are created at runtime; do not assume all work was declared in advance.", + properties( + property("operation", "string", "create, split, update, transition, add_dependency, get, list, or runnable."), + property("taskId", "string", "Task id; defaults to the current task where applicable."), + property("title", "string", "Task title for create."), + property("goal", "string", "Task goal."), + property("plan", "string", "Current plan or intended next steps."), + property("parentTaskId", "string", "Parent task id for a new task or split."), + property("dependencyTaskId", "string", "Task that must finish before taskId can run."), + property("idempotencyKey", "string", "Stable retry key for create operations."), + property("status", "string", "PLANNED, ACTIVE, WAITING, BLOCKED, or CANCELLED."), + property("reason", "string", "Reason for a transition or block."), + property("children", "array", "Child task objects used by split."), + property("metadata", "object", "Application-neutral task metadata."), + property("tags", "array", "Task tags.") + ), + Collections.singletonList("operation"))); + tools.add(tool(HarnessToolNames.FACT_RECORD, + "Record or invalidate a durable fact with its source and evidence references.", + properties( + property("operation", "string", "record or invalidate."), + property("factId", "string", "Fact id."), + property("taskId", "string", "Optional related task id."), + property("statement", "string", "Fact statement for record."), + property("source", "string", "Source of the fact."), + property("confidence", "string", "Confidence description."), + property("evidenceIds", "array", "Evidence ids supporting the fact."), + property("reason", "string", "Reason for invalidation."), + property("metadata", "object", "Fact metadata.") + ), + Collections.singletonList("operation"))); + tools.add(tool(HarnessToolNames.DECISION_PROPOSE, + "Propose a durable decision, or resolve one only when acting as a permitted non-Agent arbiter.", + properties( + property("operation", "string", "propose or resolve."), + property("decisionId", "string", "Decision id for resolve."), + property("taskId", "string", "Optional related task id."), + property("question", "string", "Question being decided."), + property("chosenOption", "string", "Chosen option for a proposal."), + property("rationale", "string", "Reasoning for the proposal or resolution."), + property("status", "string", "ACCEPTED or REJECTED for resolve."), + property("factIds", "array", "Facts used by the decision."), + property("evidenceIds", "array", "Evidence used by the decision.") + ), + Collections.singletonList("operation"))); + tools.add(tool(HarnessToolNames.EVIDENCE_RECORD, + "Record durable evidence produced by a tool, model step, test, or external system.", + properties( + property("evidenceId", "string", "Evidence id."), + property("taskId", "string", "Optional related task id."), + property("executionId", "string", "Optional related execution id."), + property("kind", "string", "Evidence kind, such as test, file, api, or observation."), + property("location", "string", "Location or command that produced the evidence."), + property("summary", "string", "Human-readable evidence summary."), + property("contentRef", "string", "Reference to durable content.") + ), + Collections.emptyList())); + tools.add(tool(HarnessToolNames.RELATION_MANAGE, + "Create or inspect durable relations between Harness entities. Task dependencies and parent relations are checked for cycles.", + properties( + property("operation", "string", "create, add, get, or list."), + property("relationId", "string", "Relation id for get."), + property("type", "string", "PARENT_OF, DEPENDS_ON, SUPPORTS, DERIVED_FROM, or EVIDENCE_FOR."), + property("fromKind", "string", "Source entity kind."), + property("fromId", "string", "Source entity id."), + property("toKind", "string", "Target entity kind."), + property("toId", "string", "Target entity id."), + property("metadata", "object", "Application-neutral relation metadata.") + ), + Collections.singletonList("operation"))); + tools.add(tool(HarnessToolNames.CONTROL_REQUEST, + "Request a durable checkpoint, external/user input, asynchronous operation wait, or approval. The host delivers waits; an Agent cannot self-approve.", + properties( + property("operation", "string", "checkpoint, wait, or approval."), + property("type", "string", "Wait type: TIME, EXTERNAL_EVENT, ASYNC_OPERATION, APPROVAL, USER_INPUT, or RETRY."), + property("waitId", "string", "Optional stable wait id."), + property("operationId", "string", "External asynchronous operation id."), + property("externalKey", "string", "External event or approval key."), + property("toolName", "string", "Tool requiring approval."), + property("callId", "string", "Tool call requiring approval."), + property("arguments", "string", "Arguments requiring approval."), + property("summary", "string", "Checkpoint or wait summary."), + property("state", "object", "Checkpoint state."), + property("payload", "object", "Wait payload.") + ), + Collections.singletonList("operation"))); + tools.add(tool(HarnessToolNames.SUBMISSION_REQUEST, + "Submit a Task for external review. Submission is not completion; an Agent cannot approve its own submission or mark a Task done.", + properties( + property("taskId", "string", "Task to submit; defaults to the current task."), + property("executionId", "string", "Execution that produced the submission."), + property("completionClaim", "string", "What the Agent claims is complete."), + property("verificationNotes", "string", "Verification performed."), + property("deliverables", "array", "Deliverable references."), + property("evidenceIds", "array", "Evidence supporting the submission."), + property("knownGaps", "array", "Known gaps."), + property("residualRisks", "array", "Residual risks.") + ), + Collections.emptyList())); + return Collections.unmodifiableList(toObjectList(tools)); + } + + private Tool tool(String name, + String description, + Map properties, + List required) { + Tool.Function.Parameter parameter = new Tool.Function.Parameter( + "object", properties, required); + return new Tool("function", new Tool.Function(name, description, parameter)); + } + + private Map properties(ToolProperty... values) { + Map result = new LinkedHashMap(); + if (values != null) { + for (ToolProperty value : values) { + if (value != null) { + result.put(value.name, new Tool.Function.Property(value.type, value.description, null, null)); + } + } + } + return result; + } + + private ToolProperty property(String name, String type, String description) { + return new ToolProperty(name, type, description); + } + + private List toObjectList(List source) { + return new ArrayList(source); + } + + private static final class ToolProperty { + private final String name; + private final String type; + private final String description; + + private ToolProperty(String name, String type, String description) { + this.name = name; + this.type = type; + this.description = description; + } + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessValidationException.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessValidationException.java new file mode 100644 index 00000000..29bf2d3d --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessValidationException.java @@ -0,0 +1,9 @@ +package io.github.lnyocly.ai4j.harness; + +/** Invalid command or state transition at the Harness boundary. */ +public class HarnessValidationException extends HarnessStoreException { + + public HarnessValidationException(String message) { + super(message); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessWaitSpec.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessWaitSpec.java new file mode 100644 index 00000000..07371436 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/HarnessWaitSpec.java @@ -0,0 +1,25 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class HarnessWaitSpec { + + private String waitId; + private WaitType type; + private String operationId; + private String externalKey; + private long dueAtEpochMs; + + @Builder.Default + private Map payload = new LinkedHashMap(); +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/JdbcHarnessStore.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/JdbcHarnessStore.java new file mode 100644 index 00000000..697520a9 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/JdbcHarnessStore.java @@ -0,0 +1,268 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +/** JDBC store using a transaction and row lock per stable harness id. */ +public final class JdbcHarnessStore implements HarnessStore { + + private static final int DEFAULT_JOURNAL_RETENTION_VERSIONS = 128; + + private final DataSource dataSource; + private final String harnessId; + private final int journalRetentionVersions; + + public JdbcHarnessStore(DataSource dataSource, String harnessId) { + this(dataSource, harnessId, DEFAULT_JOURNAL_RETENTION_VERSIONS); + } + + public JdbcHarnessStore(DataSource dataSource, + String harnessId, + int journalRetentionVersions) { + if (dataSource == null) { + throw new IllegalArgumentException("dataSource is required"); + } + this.dataSource = dataSource; + this.harnessId = harnessId == null || harnessId.trim().isEmpty() ? "default" : harnessId.trim(); + this.journalRetentionVersions = journalRetentionVersions; + initializeSchema(); + } + + public void initializeSchema() { + try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.executeUpdate("CREATE TABLE IF NOT EXISTS ai4j_harness_state (" + + "harness_id VARCHAR(255) PRIMARY KEY, version BIGINT NOT NULL, " + + "state_json TEXT NOT NULL, updated_at BIGINT NOT NULL)"); + statement.executeUpdate("CREATE TABLE IF NOT EXISTS ai4j_harness_journal (" + + "harness_id VARCHAR(255) NOT NULL, version BIGINT NOT NULL, " + + "state_json TEXT NOT NULL, recorded_at BIGINT NOT NULL, " + + "PRIMARY KEY (harness_id, version))"); + ensureStateRow(connection); + } catch (SQLException error) { + throw new HarnessStoreException("cannot initialize JDBC Harness schema", error); + } + } + + private void ensureStateRow(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO ai4j_harness_state (harness_id, version, state_json, updated_at) VALUES (?, 0, ?, 0)")) { + statement.setString(1, harnessId); + statement.setString(2, JSON.toJSONString(HarnessState.empty(harnessId))); + statement.executeUpdate(); + } catch (SQLException error) { + if (!isDuplicateKey(error)) { + throw error; + } + // Another process initialized the same harness row first. + } + } + + private boolean isDuplicateKey(SQLException error) { + SQLException current = error; + while (current != null) { + String sqlState = current.getSQLState(); + if (sqlState != null && sqlState.startsWith("23")) { + return true; + } + current = current.getNextException(); + } + return false; + } + + @Override + public HarnessState load() { + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "SELECT version, state_json FROM ai4j_harness_state WHERE harness_id = ?")) { + statement.setString(1, harnessId); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + return HarnessState.empty(harnessId); + } + HarnessState state = decodeState(result.getString("state_json")); + state.setVersion(result.getLong("version")); + return state.copy(); + } + } catch (HarnessStoreException error) { + throw error; + } catch (SQLException error) { + throw new HarnessStoreException("cannot load JDBC Harness state", error); + } + } + + @Override + public HarnessState update(HarnessStateMutation mutation) { + if (mutation == null) { + throw new IllegalArgumentException("Harness mutation is required"); + } + try (Connection connection = dataSource.getConnection()) { + boolean previousAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + boolean committed = false; + try { + HarnessState current = selectForUpdate(connection); + HarnessState next = mutation.apply(current.copy()); + if (next == null) { + throw new HarnessStoreException("Harness mutation returned null"); + } + next.ensureCollections(); + next.setHarnessId(harnessId); + next.setVersion(current.getVersion() + 1L); + next.setUpdatedAtEpochMs(System.currentTimeMillis()); + String json = JSON.toJSONString(next); + if (current.getVersion() == 0L && !exists(connection)) { + insertState(connection, next.getVersion(), json, next.getUpdatedAtEpochMs()); + } else { + updateState(connection, current.getVersion(), next.getVersion(), json, next.getUpdatedAtEpochMs()); + } + insertJournal(connection, next.getVersion(), json, next.getUpdatedAtEpochMs()); + pruneJournal(connection, next.getVersion()); + HarnessState result = next.copy(); + connection.commit(); + committed = true; + return result; + } catch (HarnessStoreException error) { + rollbackQuietly(connection, committed); + throw error; + } catch (Exception error) { + rollbackQuietly(connection, committed); + if (error instanceof HarnessStoreException) { + throw (HarnessStoreException) error; + } + throw new HarnessStoreException("JDBC Harness update failed", error); + } finally { + // A successful commit is already the durable result. A + // connection-pool reset failure must not turn that result into + // a false failed write. + try { + connection.setAutoCommit(previousAutoCommit); + } catch (SQLException ignored) { + // Closing or pooling the connection remains responsible for + // resetting its transaction state. + } + } + } catch (HarnessStoreException error) { + throw error; + } catch (SQLException error) { + throw new HarnessStoreException("JDBC Harness transaction failed", error); + } + } + + private void rollbackQuietly(Connection connection, boolean committed) { + if (committed) { + return; + } + try { + connection.rollback(); + } catch (SQLException ignored) { + // Preserve the original validation or transaction failure. + } + } + + private HarnessState selectForUpdate(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT version, state_json FROM ai4j_harness_state WHERE harness_id = ? FOR UPDATE")) { + statement.setString(1, harnessId); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + return HarnessState.empty(harnessId); + } + HarnessState state = decodeState(result.getString("state_json")); + state.setVersion(result.getLong("version")); + return state; + } + } + } + + private boolean exists(Connection connection) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "SELECT 1 FROM ai4j_harness_state WHERE harness_id = ?")) { + statement.setString(1, harnessId); + try (ResultSet result = statement.executeQuery()) { + return result.next(); + } + } + } + + private void insertState(Connection connection, long version, String json, long updatedAt) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO ai4j_harness_state (harness_id, version, state_json, updated_at) VALUES (?, ?, ?, ?)")) { + statement.setString(1, harnessId); + statement.setLong(2, version); + statement.setString(3, json); + statement.setLong(4, updatedAt); + statement.executeUpdate(); + } + } + + private void updateState(Connection connection, long expectedVersion, long nextVersion, + String json, long updatedAt) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "UPDATE ai4j_harness_state SET version = ?, state_json = ?, updated_at = ? " + + "WHERE harness_id = ? AND version = ?")) { + statement.setLong(1, nextVersion); + statement.setString(2, json); + statement.setLong(3, updatedAt); + statement.setString(4, harnessId); + statement.setLong(5, expectedVersion); + if (statement.executeUpdate() != 1) { + throw new HarnessStoreException("JDBC Harness compare-and-set failed"); + } + } + } + + private void insertJournal(Connection connection, long version, String json, long recordedAt) throws SQLException { + try (PreparedStatement statement = connection.prepareStatement( + "INSERT INTO ai4j_harness_journal (harness_id, version, state_json, recorded_at) VALUES (?, ?, ?, ?)")) { + statement.setString(1, harnessId); + statement.setLong(2, version); + statement.setString(3, json); + statement.setLong(4, recordedAt); + statement.executeUpdate(); + } + } + + private void pruneJournal(Connection connection, long latestVersion) throws SQLException { + if (journalRetentionVersions <= 0) { + return; + } + long firstRetainedVersion = Math.max(1L, + latestVersion - journalRetentionVersions + 1L); + try (PreparedStatement statement = connection.prepareStatement( + "DELETE FROM ai4j_harness_journal WHERE harness_id = ? AND version < ?")) { + statement.setString(1, harnessId); + statement.setLong(2, firstRetainedVersion); + statement.executeUpdate(); + } + } + + private HarnessState decodeState(String json) { + try { + if (json == null || json.trim().isEmpty()) { + throw new IllegalArgumentException("state JSON is empty"); + } + HarnessState state = JSON.parseObject(json, HarnessState.class); + if (state == null) { + throw new IllegalArgumentException("state JSON decoded to null"); + } + if (state.getHarnessId() != null && !state.getHarnessId().trim().isEmpty() + && !harnessId.equals(state.getHarnessId())) { + throw new HarnessStoreException("Harness id mismatch: expected " + harnessId + + ", found " + state.getHarnessId()); + } + state.setHarnessId(harnessId); + state.ensureCollections(); + return state; + } catch (HarnessStoreException error) { + throw error; + } catch (RuntimeException error) { + throw new HarnessStoreException("cannot decode JDBC Harness state for " + harnessId, error); + } + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/LeaseRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/LeaseRecord.java new file mode 100644 index 00000000..bec06542 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/LeaseRecord.java @@ -0,0 +1,29 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class LeaseRecord { + + private String leaseId; + private String executionId; + private String workerId; + private long fencingToken; + private long acquiredAtEpochMs; + private long expiresAtEpochMs; + private long releasedAtEpochMs; + + public LeaseRecord copy() { + return HarnessJson.copy(this, LeaseRecord.class); + } + + public boolean isExpired(long now) { + return releasedAtEpochMs > 0 || expiresAtEpochMs <= now; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/RelationRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/RelationRecord.java new file mode 100644 index 00000000..7bbbbabd --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/RelationRecord.java @@ -0,0 +1,33 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class RelationRecord { + + private String relationId; + private String scopeKey; + private RelationType type; + private EntityKind fromKind; + private String fromId; + private EntityKind toKind; + private String toId; + private String createdBy; + private long createdAtEpochMs; + + @Builder.Default + private Map metadata = new LinkedHashMap(); + + public RelationRecord copy() { + return HarnessJson.copy(this, RelationRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/RelationType.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/RelationType.java new file mode 100644 index 00000000..8479dbf1 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/RelationType.java @@ -0,0 +1,9 @@ +package io.github.lnyocly.ai4j.harness; + +public enum RelationType { + PARENT_OF, + DEPENDS_ON, + SUPPORTS, + DERIVED_FROM, + EVIDENCE_FOR +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ReviewRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ReviewRecord.java new file mode 100644 index 00000000..8eca6162 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ReviewRecord.java @@ -0,0 +1,26 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class ReviewRecord { + + private String reviewId; + private String submissionId; + private String taskId; + private HarnessActor reviewer; + private ReviewVerdict verdict; + private String findings; + private String rationale; + private long createdAtEpochMs; + + public ReviewRecord copy() { + return HarnessJson.copy(this, ReviewRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ReviewVerdict.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ReviewVerdict.java new file mode 100644 index 00000000..b456ca22 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ReviewVerdict.java @@ -0,0 +1,7 @@ +package io.github.lnyocly.ai4j.harness; + +public enum ReviewVerdict { + APPROVED, + CHANGES_REQUESTED, + DISMISSED +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/SessionLeaseRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/SessionLeaseRecord.java new file mode 100644 index 00000000..692dc452 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/SessionLeaseRecord.java @@ -0,0 +1,31 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Durable mutex for one Agent session identity across Harness instances. */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class SessionLeaseRecord { + + private String sessionId; + private String executionId; + private String workerId; + private String leaseId; + private long fencingToken; + private long acquiredAtEpochMs; + private long expiresAtEpochMs; + private long releasedAtEpochMs; + + public SessionLeaseRecord copy() { + return HarnessJson.copy(this, SessionLeaseRecord.class); + } + + public boolean isExpired(long now) { + return releasedAtEpochMs > 0L || expiresAtEpochMs <= now; + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/SubmissionRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/SubmissionRecord.java new file mode 100644 index 00000000..73092b66 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/SubmissionRecord.java @@ -0,0 +1,40 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class SubmissionRecord { + + private String submissionId; + private String taskId; + private String executionId; + private HarnessActor submitter; + private String completionClaim; + private String verificationNotes; + private long createdAtEpochMs; + + @Builder.Default + private List deliverables = new ArrayList(); + + @Builder.Default + private List evidenceIds = new ArrayList(); + + @Builder.Default + private List knownGaps = new ArrayList(); + + @Builder.Default + private List residualRisks = new ArrayList(); + + public SubmissionRecord copy() { + return HarnessJson.copy(this, SubmissionRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/TaskRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/TaskRecord.java new file mode 100644 index 00000000..d70e32aa --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/TaskRecord.java @@ -0,0 +1,42 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class TaskRecord { + + private String taskId; + private String scopeKey; + private String title; + private String goal; + private String plan; + private TaskStatus status; + private String blockedReason; + private String lastExecutionId; + private String submissionId; + private String createdBy; + private long createdAtEpochMs; + private long updatedAtEpochMs; + private long version; + + @Builder.Default + private List tags = new ArrayList(); + + @Builder.Default + private Map metadata = new LinkedHashMap(); + + public TaskRecord copy() { + return HarnessJson.copy(this, TaskRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/TaskStatus.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/TaskStatus.java new file mode 100644 index 00000000..eb69af28 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/TaskStatus.java @@ -0,0 +1,11 @@ +package io.github.lnyocly.ai4j.harness; + +public enum TaskStatus { + PLANNED, + ACTIVE, + BLOCKED, + WAITING, + IN_REVIEW, + DONE, + CANCELLED +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationRecord.java new file mode 100644 index 00000000..ce25460a --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationRecord.java @@ -0,0 +1,42 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Durable invocation identity and outcome for a business tool call. + * + *

A STARTED invocation is deliberately not retried automatically after a + * crash. The external operation may already have happened, so an operator or + * an idempotent business lookup must reconcile it first.

+ */ +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class ToolInvocationRecord { + + private String invocationId; + private String executionId; + private String taskId; + private String sessionId; + private String scopeKey; + private String toolName; + private String callId; + private String arguments; + private ToolInvocationStatus status; + private String operationId; + private String waitId; + private String output; + private String error; + private String createdBy; + private long createdAtEpochMs; + private long updatedAtEpochMs; + private long version; + + public ToolInvocationRecord copy() { + return HarnessJson.copy(this, ToolInvocationRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationReservation.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationReservation.java new file mode 100644 index 00000000..7edee6c5 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationReservation.java @@ -0,0 +1,25 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Result of atomically reserving a durable tool invocation. + * + *

Only the caller that creates the {@link ToolInvocationRecord} may invoke + * the external tool. A reservation that returns an existing record is a + * recovery observation, including when that record is still {@link + * ToolInvocationStatus#STARTED}; it must not replay an operation whose side + * effect may already have happened.

+ */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ToolInvocationReservation { + + private ToolInvocationRecord invocation; + private boolean created; +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationStatus.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationStatus.java new file mode 100644 index 00000000..883c42aa --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/ToolInvocationStatus.java @@ -0,0 +1,11 @@ +package io.github.lnyocly.ai4j.harness; + +/** Durable state of a business tool invocation. */ +public enum ToolInvocationStatus { + STARTED, + WAITING, + SUCCEEDED, + FAILED, + UNKNOWN, + CANCELLED +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitRecord.java new file mode 100644 index 00000000..ae9ece1b --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitRecord.java @@ -0,0 +1,34 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class WaitRecord { + + private String waitId; + private String executionId; + private String taskId; + private WaitType type; + private WaitStatus status; + private String operationId; + private String externalKey; + private long dueAtEpochMs; + private long createdAtEpochMs; + private long resolvedAtEpochMs; + + @Builder.Default + private Map payload = new LinkedHashMap(); + + public WaitRecord copy() { + return HarnessJson.copy(this, WaitRecord.class); + } +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitStatus.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitStatus.java new file mode 100644 index 00000000..a625d067 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitStatus.java @@ -0,0 +1,7 @@ +package io.github.lnyocly.ai4j.harness; + +public enum WaitStatus { + OPEN, + DELIVERED, + CANCELLED +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitType.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitType.java new file mode 100644 index 00000000..0d7b0bed --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WaitType.java @@ -0,0 +1,10 @@ +package io.github.lnyocly.ai4j.harness; + +public enum WaitType { + TIME, + EXTERNAL_EVENT, + ASYNC_OPERATION, + APPROVAL, + USER_INPUT, + RETRY +} diff --git a/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WakeupRecord.java b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WakeupRecord.java new file mode 100644 index 00000000..bba06188 --- /dev/null +++ b/ai4j-harness/src/main/java/io/github/lnyocly/ai4j/harness/WakeupRecord.java @@ -0,0 +1,30 @@ +package io.github.lnyocly.ai4j.harness; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class WakeupRecord { + + private String wakeupId; + private String waitId; + private String executionId; + private WaitType type; + private long dueAtEpochMs; + private long deliveredAtEpochMs; + + @Builder.Default + private Map payload = new LinkedHashMap(); + + public WakeupRecord copy() { + return HarnessJson.copy(this, WakeupRecord.class); + } +} diff --git a/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/AgentHarnessTest.java b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/AgentHarnessTest.java new file mode 100644 index 00000000..33a830de --- /dev/null +++ b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/AgentHarnessTest.java @@ -0,0 +1,906 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import io.github.lnyocly.ai4j.agent.Agent; +import io.github.lnyocly.ai4j.agent.AgentContext; +import io.github.lnyocly.ai4j.agent.AgentExecutionStatus; +import io.github.lnyocly.ai4j.agent.AgentOptions; +import io.github.lnyocly.ai4j.agent.AgentRequest; +import io.github.lnyocly.ai4j.agent.AgentResult; +import io.github.lnyocly.ai4j.agent.codeact.CodeActOptions; +import io.github.lnyocly.ai4j.agent.codeact.NashornCodeExecutor; +import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; +import io.github.lnyocly.ai4j.agent.model.AgentModelClient; +import io.github.lnyocly.ai4j.agent.model.AgentModelResult; +import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; +import io.github.lnyocly.ai4j.agent.model.AgentPrompt; +import io.github.lnyocly.ai4j.agent.permission.AgentPermissionPolicies; +import io.github.lnyocly.ai4j.agent.permission.AgentPermissionToolExecutor; +import io.github.lnyocly.ai4j.agent.runtime.CodeActRuntime; +import io.github.lnyocly.ai4j.agent.runtime.ReActRuntime; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; +import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import org.junit.After; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Test; + +import javax.script.ScriptEngineManager; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +public class AgentHarnessTest { + + private Path directory; + + @Before + public void setUp() throws Exception { + directory = Files.createTempDirectory("ai4j-agent-harness-test-"); + } + + @After + public void tearDown() throws Exception { + if (directory != null && Files.exists(directory)) { + Files.walk(directory) + .sorted(java.util.Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + // Best effort cleanup of the test directory. + } + }); + } + } + + @Test + public void runtimeCanCreateAndAttachItsFirstTaskWithoutPredeclaringIt() { + QueueModelClient model = new QueueModelClient( + toolCallResult("task-call", HarnessToolNames.TASK_MANAGE, + "{\"operation\":\"create\",\"taskId\":\"runtime-task\"," + + "\"title\":\"Discovered work\",\"goal\":\"Inspect the input\"}"), + textResult("finished")); + AgentHarness harness = harness(newAgent(new ReActRuntime(), model, + new NoopToolExecutor(), StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build()), + "dynamic"); + + HarnessRunResult result = harness.run(HarnessRunRequest.builder() + .sessionId("session-runtime") + .input("discover the work from this message") + .build()); + + Assert.assertEquals(HarnessRunStatus.COMPLETED, result.getStatus()); + Assert.assertNotNull(result.getTask()); + Assert.assertEquals("runtime-task", result.getTask().getTaskId()); + Assert.assertEquals("runtime-task", result.getExecution().getTaskId()); + Assert.assertEquals("finished", result.getOutputText()); + harness.close(); + } + + @Test + public void boundedSlicesResumeWithoutBindingTaskToOneSession() { + QueueModelClient model = new QueueModelClient( + toolCallResult("slice-call", "echo", "{}"), textResult("slice finished")); + AgentHarness harness = harness(newAgent(new ReActRuntime(), model, + new NoopToolExecutor(), StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build()), + "slices"); + HarnessRunResult first = harness.run(HarnessRunRequest.builder() + .taskId(createTask(harness, "slice-task").getTaskId()) + .sessionId("coding-session") + .input("continue the coding task") + .budget(HarnessRunBudget.builder().maxSteps(1).build()) + .build()); + + Assert.assertEquals(HarnessRunStatus.CONTINUATION_REQUIRED, first.getStatus()); + Assert.assertEquals(ExecutionStatus.READY, first.getExecution().getStatus()); + Assert.assertEquals("coding-session", first.getExecution().getSessionId()); + + HarnessRunResult resumed = harness.resume(first.getExecution().getExecutionId()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("slice finished", resumed.getOutputText()); + harness.close(); + } + + @Test + public void longRunningCodingTaskResumesFromCheckpointAfterHarnessReopen() { + Path persistenceDirectory = directory.resolve("coding-restart"); + QueueModelClient firstModel = new QueueModelClient( + toolCallResult("workspace-call", "inspectWorkspace", "{}")); + AgentHarness first = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), firstModel, new NoopToolExecutor(), + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(persistenceDirectory)) + .autoResume(false) + .build(); + TaskRecord task = createTask(first, "coding-long-task"); + + HarnessRunResult slice = first.run(HarnessRunRequest.builder() + .taskId(task.getTaskId()) + .sessionId("coding-project-session") + .input("inspect the repository and continue the implementation") + .budget(HarnessRunBudget.builder().maxSteps(1).build()) + .build()); + Assert.assertEquals(HarnessRunStatus.CONTINUATION_REQUIRED, slice.getStatus()); + Assert.assertEquals(ExecutionStatus.READY, slice.getExecution().getStatus()); + Assert.assertNotNull(slice.getExecution().getCheckpointId()); + Assert.assertNotNull(first.getGateway().getSessionSnapshot("coding-project-session")); + String executionId = slice.getExecution().getExecutionId(); + first.close(); + + AgentHarness reopened = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), new QueueModelClient( + textResult("coding task completed after restart")), + new NoopToolExecutor(), StaticToolRegistry.empty(), + AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(persistenceDirectory)) + .autoResume(false) + .build(); + try { + HarnessRunResult resumed = reopened.resume(executionId); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("coding task completed after restart", resumed.getOutputText()); + Assert.assertEquals("coding-project-session", resumed.getExecution().getSessionId()); + Assert.assertEquals(TaskStatus.ACTIVE, + reopened.getGateway().getTask(task.getTaskId()).getStatus()); + } finally { + reopened.close(); + } + } + + @Test + public void independentCustomerSessionsDoNotShareAgentMemory() { + AgentHarness harness = harness(newAgent(new ReActRuntime(), new RepeatingModelClient(), + new NoopToolExecutor(), StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(2).build()), + "customer-sessions"); + + HarnessRunResult customerA = harness.run(HarnessRunRequest.builder() + .sessionId("customer-A") + .input("A-private-question") + .build()); + HarnessRunResult customerB = harness.run(HarnessRunRequest.builder() + .sessionId("customer-B") + .input("B-private-question") + .build()); + + String memoryA = JSON.toJSONString( + harness.getGateway().getSessionSnapshot("customer-A").getMemory().getItems()); + String memoryB = JSON.toJSONString( + harness.getGateway().getSessionSnapshot("customer-B").getMemory().getItems()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, customerA.getStatus()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, customerB.getStatus()); + Assert.assertTrue(memoryA.contains("A-private-question")); + Assert.assertFalse(memoryA.contains("B-private-question")); + Assert.assertTrue(memoryB.contains("B-private-question")); + Assert.assertFalse(memoryB.contains("A-private-question")); + harness.close(); + } + + @Test + public void consecutiveMessagesReuseSessionSnapshotButCreateIndependentExecutions() { + QueueModelClient model = new QueueModelClient( + textResult("first answer"), textResult("second answer")); + AgentHarness harness = harness(newAgent(new ReActRuntime(), model, + new NoopToolExecutor(), StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(2).build()), + "consecutive-messages"); + TaskRecord task = createTask(harness, "customer-task"); + + HarnessRunResult first = harness.run(HarnessRunRequest.builder() + .taskId(task.getTaskId()) + .sessionId("customer-session") + .input("first message") + .build()); + HarnessRunResult second = harness.run(HarnessRunRequest.builder() + .sessionId("customer-session") + .input("second message") + .build()); + + Assert.assertEquals(HarnessRunStatus.COMPLETED, first.getStatus()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, second.getStatus()); + Assert.assertNotEquals(first.getExecution().getExecutionId(), + second.getExecution().getExecutionId()); + Assert.assertEquals("customer-session", first.getExecution().getSessionId()); + Assert.assertEquals("customer-session", second.getExecution().getSessionId()); + Assert.assertEquals("a Session keeps one stable Agent run identity", + first.getExecution().getRunId(), second.getExecution().getRunId()); + Assert.assertEquals(task.getTaskId(), first.getExecution().getTaskId()); + Assert.assertNull("a later message does not inherit the earlier task", second.getExecution().getTaskId()); + + String memory = JSON.toJSONString( + harness.getGateway().getSessionSnapshot("customer-session").getMemory().getItems()); + Assert.assertTrue(memory.contains("first message")); + Assert.assertTrue(memory.contains("second message")); + Assert.assertEquals(first.getExecution().getExecutionId(), + harness.getGateway().getTask(task.getTaskId()).getLastExecutionId()); + harness.close(); + } + + @Test + public void userInputWaitIsDurableAndResumesThroughTheHostBoundary() { + QueueModelClient model = new QueueModelClient( + toolCallResult("input-call", HarnessToolNames.CONTROL_REQUEST, + "{\"operation\":\"wait\",\"type\":\"USER_INPUT\"," + + "\"externalKey\":\"refund-order\"}"), + textResult("user input handled")); + AgentHarness harness = harness(newAgent(new ReActRuntime(), model, + new NoopToolExecutor(), StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build()), + "user-input-wait"); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("customer-input") + .input("which order should be refunded?") + .build()); + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + Assert.assertEquals(WaitType.USER_INPUT, + harness.getGateway().getWait(waiting.getWaitId()).getType()); + + HarnessRunResult resumed = harness.deliver(waiting.getWaitId(), "order-42"); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("user input handled", resumed.getOutputText()); + harness.close(); + } + + @Test + public void approvalWaitIsDurableAndTheApprovedToolRunsOnlyAfterDelivery() { + QueueModelClient model = new QueueModelClient( + toolCallResult("approval-call", "submitRefund", "{\"orderId\":\"order-7\"}"), + toolCallResult("approval-retry", "submitRefund", "{\"orderId\":\"order-7\"}"), + textResult("refund submitted")); + HarnessContract contract = HarnessContract.builder() + .approvalRequiredTool("submitRefund") + .build(); + final AtomicInteger businessCalls = new AtomicInteger(); + AgentHarness harness = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), model, new ToolExecutor() { + @Override + public String execute(AgentToolCall call) { + businessCalls.incrementAndGet(); + return "refund accepted"; + } + }, + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(directory.resolve("approval-wait"))) + .contract(contract) + .autoResume(false) + .build(); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("customer-approval") + .input("please refund order-7") + .build()); + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + Assert.assertEquals(WaitType.APPROVAL, + harness.getGateway().getWait(waiting.getWaitId()).getType()); + Assert.assertFalse(harness.getGateway().getWait(waiting.getWaitId()).getStatus() + == WaitStatus.DELIVERED); + + HarnessRunResult resumed = harness.deliver(waiting.getWaitId(), Boolean.TRUE); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("refund submitted", resumed.getOutputText()); + Assert.assertEquals(1, businessCalls.get()); + harness.close(); + } + + @Test + public void permissionApprovalAfterReservationResumesWithChangedProviderCallId() { + QueueModelClient model = new QueueModelClient( + toolCallResult("permission-call", "submitRefund", "{\"orderId\":\"order-7\"}"), + toolCallResult("permission-retry", "submitRefund", "{\"orderId\":\"order-7\"}"), + textResult("refund submitted")); + final AtomicInteger businessCalls = new AtomicInteger(); + ToolExecutor delegate = new ToolExecutor() { + @Override + public String execute(AgentToolCall ignored) { + businessCalls.incrementAndGet(); + return "refund accepted"; + } + }; + AgentPermissionToolExecutor permissionExecutor = new AgentPermissionToolExecutor( + delegate, + AgentPermissionPolicies.requireApprovalForTools( + Collections.singleton("submitRefund"), "operator approval required")); + AgentHarness harness = harness(newAgent(new ReActRuntime(), model, permissionExecutor, + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build()), + "permission-approval-resume"); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("customer-permission-approval") + .input("please refund order-7") + .build()); + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + Assert.assertEquals(1, harness.getGateway().listToolInvocationsInScope(null).size()); + Assert.assertEquals(ToolInvocationStatus.WAITING, + harness.getGateway().listToolInvocationsInScope(null).get(0).getStatus()); + + HarnessRunResult resumed = harness.deliver(waiting.getWaitId(), Boolean.TRUE); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("refund submitted", resumed.getOutputText()); + Assert.assertEquals(1, businessCalls.get()); + Assert.assertEquals(1, harness.getGateway().listToolInvocationsInScope(null).size()); + Assert.assertEquals(ToolInvocationStatus.SUCCEEDED, + harness.getGateway().listToolInvocationsInScope(null).get(0).getStatus()); + harness.close(); + } + + @Test + public void asyncWaitCanBeDeliveredByAReopenedHarnessWithoutTheOriginalFuture() { + QueueModelClient firstModel = new QueueModelClient( + toolCallResult("restart-call", "submitRefund", "{\"orderId\":\"order-9\"}")); + AgentHarness first = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), firstModel, + pendingWithoutCompletion("operation-restart"), StaticToolRegistry.empty(), + AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(directory.resolve("restarted-async"))) + .autoResume(false) + .build(); + + HarnessRunResult waiting = first.run(HarnessRunRequest.builder() + .sessionId("customer-restart") + .input("please refund order-9") + .build()); + String waitId = waiting.getWaitId(); + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + Assert.assertEquals("operation-restart", waiting.getOperationId()); + first.close(); + + QueueModelClient secondModel = new QueueModelClient(textResult("refund resumed after restart")); + AgentHarness second = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), secondModel, new NoopToolExecutor(), + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(directory.resolve("restarted-async"))) + .autoResume(false) + .build(); + + Assert.assertEquals(WaitStatus.OPEN, second.getGateway().getWait(waitId).getStatus()); + HarnessRunResult resumed = second.deliver(waitId, "refund accepted by payment service"); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("refund resumed after restart", resumed.getOutputText()); + Assert.assertEquals(WaitStatus.DELIVERED, second.getGateway().getWait(waitId).getStatus()); + second.close(); + } + + @Test + public void asynchronousFunctionCallWaitsPersistsAndCanResume() throws Exception { + CompletableFuture completion = new CompletableFuture(); + AsyncToolExecutor asyncTools = pendingExecutor(completion, "operation-refund"); + QueueModelClient model = new QueueModelClient( + toolCallResult("refund-call", "submitRefund", "{\"orderId\":\"order-1\"}"), + textResult("refund completed")); + AgentHarness harness = harness(newAgent(new ReActRuntime(), model, asyncTools, + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build()), + "async-function"); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("customer-refund") + .input("please refund order-1") + .build()); + + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + Assert.assertEquals(AgentExecutionStatus.WAITING, + waiting.getAgentResult().getExecutionStatus()); + Assert.assertNotNull(waiting.getWaitId()); + WaitRecord wait = harness.getGateway().getWait(waiting.getWaitId()); + Assert.assertEquals(WaitType.ASYNC_OPERATION, wait.getType()); + Assert.assertEquals("refund-call", wait.getPayload().get("callId")); + + completion.complete(AgentToolResult.builder() + .output("refund accepted") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + + awaitWaitStatus(harness, waiting.getWaitId(), WaitStatus.DELIVERED); + Assert.assertEquals(WaitStatus.DELIVERED, + harness.getGateway().getWait(waiting.getWaitId()).getStatus()); + Assert.assertEquals(ExecutionStatus.READY, + harness.getGateway().getExecution(waiting.getExecution().getExecutionId()).getStatus()); + HarnessRunResult resumed = harness.resume(waiting.getExecution().getExecutionId()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("refund completed", resumed.getOutputText()); + harness.close(); + } + + @Test + public void parallelAsyncWaitsRequireEveryCompletionBeforeResuming() throws Exception { + CompletableFuture firstCompletion = new CompletableFuture(); + CompletableFuture secondCompletion = new CompletableFuture(); + AgentModelResult parallelCalls = AgentModelResult.builder() + .toolCalls(Arrays.asList( + AgentToolCall.builder().callId("parallel-call-1").name("async-first") + .arguments("{}").type("function_call").build(), + AgentToolCall.builder().callId("parallel-call-2").name("async-second") + .arguments("{}").type("function_call").build())) + .memoryItems(new ArrayList()) + .build(); + AgentContext context = AgentContext.builder() + .modelClient(new QueueModelClient(parallelCalls, textResult("both operations completed"))) + .toolRegistry(StaticToolRegistry.empty()) + .toolExecutor(parallelPendingExecutor(firstCompletion, secondCompletion)) + .memory(new InMemoryAgentMemory()) + .options(AgentOptions.builder().maxSteps(4).build()) + .parallelToolCalls(Boolean.TRUE) + .model("test-model") + .build(); + AgentHarness harness = AgentHarness.builder() + .agent(new Agent(new ReActRuntime(), context, InMemoryAgentMemory::new)) + .persistence(HarnessPersistence.file(directory.resolve("parallel-async"))) + .autoResume(false) + .build(); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("parallel-session") + .input("start both long operations") + .build()); + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + List openWaits = harness.getGateway() + .listOpenWaits(waiting.getExecution().getExecutionId()); + Assert.assertEquals(2, openWaits.size()); + + WaitRecord firstWait = findWait(openWaits, "parallel-call-1"); + WaitRecord secondWait = findWait(openWaits, "parallel-call-2"); + Assert.assertNotNull(firstWait); + Assert.assertNotNull(secondWait); + + // Complete out of order. The first completion must not make the + // execution resumable while the other operation is still open. + secondCompletion.complete(AgentToolResult.builder() + .output("second operation accepted") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + awaitWaitStatus(harness, secondWait.getWaitId(), WaitStatus.DELIVERED); + Assert.assertEquals(ExecutionStatus.WAITING, + harness.getGateway().getExecution(waiting.getExecution().getExecutionId()).getStatus()); + Assert.assertEquals(1, harness.getGateway() + .listOpenWaits(waiting.getExecution().getExecutionId()).size()); + + firstCompletion.complete(AgentToolResult.builder() + .output("first operation accepted") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + awaitWaitStatus(harness, firstWait.getWaitId(), WaitStatus.DELIVERED); + Assert.assertEquals(ExecutionStatus.READY, + harness.getGateway().getExecution(waiting.getExecution().getExecutionId()).getStatus()); + + HarnessRunResult resumed = harness.resume(waiting.getExecution().getExecutionId()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("both operations completed", resumed.getOutputText()); + harness.close(); + } + + @Test + public void asynchronousCompletionNotifiesContinuationWhenAutoResumeIsDisabled() throws Exception { + CompletableFuture completion = new CompletableFuture(); + CountDownLatch callback = new CountDownLatch(1); + AtomicReference callbackResult = new AtomicReference(); + QueueModelClient model = new QueueModelClient( + toolCallResult("manual-call", "submitRefund", "{\"orderId\":\"order-2\"}"), + textResult("manual resume completed")); + AgentHarness harness = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), model, pendingExecutor(completion, "operation-manual"), + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(directory.resolve("manual-async"))) + .autoResume(false) + .listener(new HarnessRunListener() { + @Override + public void onResult(HarnessRunResult result) { + callbackResult.set(result); + callback.countDown(); + } + }) + .build(); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("manual-session") + .input("please refund order-2") + .build()); + completion.complete(AgentToolResult.builder() + .output("refund accepted") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + + Assert.assertTrue("async completion should notify the host", callback.await(5L, TimeUnit.SECONDS)); + Assert.assertNotNull(callbackResult.get()); + Assert.assertEquals(HarnessRunStatus.CONTINUATION_REQUIRED, callbackResult.get().getStatus()); + Assert.assertEquals(ExecutionStatus.READY, + harness.getGateway().getExecution(waiting.getExecution().getExecutionId()).getStatus()); + harness.close(); + } + + @Test + public void asynchronousCompletionAutoResumesAndNotifiesFinalResult() throws Exception { + CompletableFuture completion = new CompletableFuture(); + CountDownLatch callback = new CountDownLatch(1); + AtomicReference callbackResult = new AtomicReference(); + QueueModelClient model = new QueueModelClient( + toolCallResult("auto-call", "submitRefund", "{\"orderId\":\"order-3\"}"), + textResult("automatic resume completed")); + AgentHarness harness = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), model, pendingExecutor(completion, "operation-auto"), + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(directory.resolve("auto-async"))) + .listener(new HarnessRunListener() { + @Override + public void onResult(HarnessRunResult result) { + callbackResult.set(result); + callback.countDown(); + } + }) + .build(); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("auto-session") + .input("please refund order-3") + .build()); + completion.complete(AgentToolResult.builder() + .output("refund accepted") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + + Assert.assertTrue("automatic continuation should notify the host", callback.await(5L, TimeUnit.SECONDS)); + Assert.assertNotNull(callbackResult.get()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, callbackResult.get().getStatus()); + Assert.assertEquals("automatic resume completed", callbackResult.get().getOutputText()); + Assert.assertEquals(ExecutionStatus.SUCCEEDED, + harness.getGateway().getExecution(waiting.getExecution().getExecutionId()).getStatus()); + harness.close(); + } + + @Test + public void cancelledTaskQuarantinesLateAsyncCompletionAfterHumanHandoff() throws Exception { + CompletableFuture completion = new CompletableFuture(); + QueueModelClient model = new QueueModelClient( + toolCallResult("refund-call", "submitRefund", "{\"orderId\":\"order-handoff\"}"), + textResult("new conversation bot response")); + AgentHarness harness = AgentHarness.builder() + .agent(newAgent(new ReActRuntime(), model, + pendingExecutor(completion, "operation-handoff"), StaticToolRegistry.empty(), + AgentOptions.builder().maxSteps(4).build())) + .persistence(HarnessPersistence.file(directory.resolve("human-handoff"))) + .build(); + TaskRecord refundTask = createTask(harness, "refund-handoff-task"); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .taskId(refundTask.getTaskId()) + .sessionId("customer-A-conversation-1") + .input("please refund order-handoff") + .build()); + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + + harness.getGateway().transitionTask(refundTask.getTaskId(), TaskStatus.CANCELLED, + "customer was transferred to a human agent", HarnessActor.human("operator")); + completion.complete(AgentToolResult.builder() + .output("late payment result") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + + awaitWaitStatus(harness, waiting.getWaitId(), WaitStatus.CANCELLED); + Assert.assertEquals(ExecutionStatus.CANCELLED, + harness.getGateway().getExecution(waiting.getExecution().getExecutionId()).getStatus()); + + HarnessRunResult newConversation = harness.run(HarnessRunRequest.builder() + .sessionId("customer-A-conversation-2") + .input("I have a new question") + .build()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, newConversation.getStatus()); + Assert.assertEquals("new conversation bot response", newConversation.getOutputText()); + harness.close(); + } + + @Test + public void sameExecutionIsNotExecutedTwiceConcurrentlyWithinOneHarness() throws Exception { + final BlockingModelClient model = new BlockingModelClient(); + AgentHarness harness = harness(newAgent(new ReActRuntime(), model, + new NoopToolExecutor(), StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(2).build()), + "concurrent-execution"); + ExecutionRecord execution = harness.getGateway().createExecution(HarnessExecutionSpec.builder() + .executionId("concurrent-execution-id") + .build()); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(new Callable() { + @Override + public HarnessRunResult call() { + return harness.resume(execution.getExecutionId()); + } + }); + Assert.assertTrue("first execution should reach the model", model.started.await(5L, TimeUnit.SECONDS)); + + Future second = executor.submit(new Callable() { + @Override + public HarnessRunResult call() { + return harness.resume(execution.getExecutionId()); + } + }); + Thread.sleep(100L); + Assert.assertFalse("the duplicate call must wait for the local execution mutex", second.isDone()); + + model.release.countDown(); + Assert.assertEquals(HarnessRunStatus.COMPLETED, + first.get(5L, TimeUnit.SECONDS).getStatus()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, + second.get(5L, TimeUnit.SECONDS).getStatus()); + Assert.assertEquals(1, model.calls.get()); + } finally { + model.release.countDown(); + executor.shutdownNow(); + harness.close(); + } + } + + @Test + public void codeActStopsAtNestedAsyncToolAndReplacesItsOuterMarker() throws Exception { + Assume.assumeTrue("Nashorn is not available", isNashornAvailable()); + CompletableFuture completion = new CompletableFuture(); + QueueModelClient model = new QueueModelClient( + textResult("{\"type\":\"code\",\"language\":\"js\"," + + "\"code\":\"callTool('slowOperation', {}); return 'unreachable';\"}"), + textResult("code operation completed")); + AgentContext context = AgentContext.builder() + .modelClient(model) + .toolRegistry(StaticToolRegistry.empty()) + .toolExecutor(pendingExecutor(completion, "operation-code")) + .memory(new InMemoryAgentMemory()) + .options(AgentOptions.builder().maxSteps(4).build()) + .codeActOptions(CodeActOptions.builder().reAct(false).build()) + .codeExecutor(new NashornCodeExecutor()) + .model("test-model") + .build(); + Agent agent = new Agent(new CodeActRuntime(), context, InMemoryAgentMemory::new); + AgentHarness harness = AgentHarness.builder() + .agent(agent) + .persistence(HarnessPersistence.file(directory.resolve("codeact"))) + .autoResume(false) + .build(); + + HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .sessionId("code-session") + .input("run the long operation") + .build()); + + Assert.assertEquals(HarnessRunStatus.WAITING, waiting.getStatus()); + Assert.assertEquals(AgentExecutionStatus.WAITING, + waiting.getAgentResult().getExecutionStatus()); + WaitRecord wait = harness.getGateway().getWait(waiting.getWaitId()); + Assert.assertEquals("code_execution_0:tool:0", wait.getPayload().get("callId")); + Assert.assertEquals("code_execution_0", + wait.getPayload().get(AgentToolCall.METADATA_KEY_PARENT_CALL_ID)); + + completion.complete(AgentToolResult.builder() + .output("slow operation accepted") + .status(AgentToolExecutionStatus.COMPLETED) + .build()); + + awaitWaitStatus(harness, waiting.getWaitId(), WaitStatus.DELIVERED); + String restored = JSON.toJSONString(harness.getGateway() + .getSessionSnapshot("code-session").getMemory().getItems()); + Assert.assertTrue(restored.contains("CODE_RESULT: slow operation accepted")); + Assert.assertFalse(restored.contains(AgentToolCall.CODEACT_PENDING_RESULT_PREFIX)); + HarnessRunResult resumed = harness.resume(waiting.getExecution().getExecutionId()); + Assert.assertEquals(HarnessRunStatus.COMPLETED, resumed.getStatus()); + Assert.assertEquals("code operation completed", resumed.getOutputText()); + harness.close(); + } + + @Test + public void plainAgentWithoutHarnessKeepsItsExistingBehavior() throws Exception { + QueueModelClient model = new QueueModelClient(textResult("plain answer")); + Agent agent = newAgent(new ReActRuntime(), model, new NoopToolExecutor(), + StaticToolRegistry.empty(), AgentOptions.builder().maxSteps(2).build()); + + AgentResult result = agent.run(AgentRequest.builder().input("plain input").build()); + + Assert.assertEquals("plain answer", result.getOutputText()); + Assert.assertEquals(AgentExecutionStatus.COMPLETED, result.getExecutionStatus()); + Assert.assertEquals(0, result.getToolCalls().size()); + } + + private AgentHarness harness(Agent agent, String name) { + return AgentHarness.builder() + .agent(agent) + .persistence(HarnessPersistence.file(directory.resolve(name))) + .autoResume(false) + .build(); + } + + private Agent newAgent(io.github.lnyocly.ai4j.agent.AgentRuntime runtime, + AgentModelClient model, + ToolExecutor executor, + AgentToolRegistry registry, + AgentOptions options) { + AgentContext context = AgentContext.builder() + .modelClient(model) + .toolRegistry(registry) + .toolExecutor(executor) + .memory(new InMemoryAgentMemory()) + .options(options) + .model("test-model") + .build(); + return new Agent(runtime, context, InMemoryAgentMemory::new); + } + + private TaskRecord createTask(AgentHarness harness, String taskId) { + return harness.getGateway().createTask(HarnessTaskSpec.builder() + .taskId(taskId) + .title(taskId) + .build()); + } + + private AsyncToolExecutor pendingExecutor(final CompletableFuture completion, + final String operationId) { + return new AsyncToolExecutor() { + @Override + public AgentToolExecution start(AgentToolCall call) { + return AgentToolExecution.pending(operationId, null, "operation is pending", + null, completion); + } + }; + } + + private AsyncToolExecutor pendingWithoutCompletion(final String operationId) { + return new AsyncToolExecutor() { + @Override + public AgentToolExecution start(AgentToolCall call) { + return AgentToolExecution.pending(operationId, null, "operation is pending"); + } + }; + } + + private AsyncToolExecutor parallelPendingExecutor( + final CompletableFuture firstCompletion, + final CompletableFuture secondCompletion) { + return new AsyncToolExecutor() { + @Override + public AgentToolExecution start(AgentToolCall call) { + boolean first = "async-first".equals(call.getName()); + return AgentToolExecution.pending( + first ? "operation-first" : "operation-second", + null, + "operation is pending", + null, + first ? firstCompletion : secondCompletion); + } + }; + } + + private AgentModelResult toolCallResult(String callId, String name, String arguments) { + return AgentModelResult.builder() + .toolCalls(Collections.singletonList(AgentToolCall.builder() + .callId(callId) + .name(name) + .arguments(arguments) + .type("function_call") + .build())) + .memoryItems(new ArrayList()) + .build(); + } + + private AgentModelResult textResult(String text) { + return AgentModelResult.builder() + .outputText(text) + .toolCalls(new ArrayList()) + .memoryItems(new ArrayList()) + .build(); + } + + private boolean isNashornAvailable() { + return new ScriptEngineManager().getEngineByName("nashorn") != null; + } + + private void awaitWaitStatus(AgentHarness harness, + String waitId, + WaitStatus expected) throws Exception { + long deadline = System.currentTimeMillis() + 5_000L; + WaitStatus actual = null; + while (System.currentTimeMillis() < deadline) { + WaitRecord wait = harness.getGateway().getWait(waitId); + actual = wait == null ? null : wait.getStatus(); + if (expected == actual) { + return; + } + Thread.sleep(10L); + } + Assert.assertEquals(expected, actual); + } + + private WaitRecord findWait(List waits, String callId) { + for (WaitRecord wait : waits) { + if (wait != null && wait.getPayload() != null + && callId.equals(String.valueOf(wait.getPayload().get("callId")))) { + return wait; + } + } + return null; + } + + private static class QueueModelClient implements AgentModelClient { + private final Deque results; + + private QueueModelClient(AgentModelResult... results) { + this.results = new ArrayDeque(Arrays.asList(results)); + } + + @Override + public AgentModelResult create(AgentPrompt prompt) { + return results.isEmpty() ? AgentModelResult.builder() + .outputText("no more model results") + .toolCalls(new ArrayList()) + .build() : results.poll(); + } + + @Override + public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { + return create(prompt); + } + } + + private static class RepeatingModelClient implements AgentModelClient { + @Override + public AgentModelResult create(AgentPrompt prompt) { + return AgentModelResult.builder() + .outputText("acknowledged") + .toolCalls(new ArrayList()) + .memoryItems(new ArrayList()) + .build(); + } + + @Override + public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { + return create(prompt); + } + } + + private static class BlockingModelClient implements AgentModelClient { + private final CountDownLatch started = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicInteger calls = new AtomicInteger(); + + @Override + public AgentModelResult create(AgentPrompt prompt) { + calls.incrementAndGet(); + started.countDown(); + try { + release.await(5L, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return AgentModelResult.builder() + .outputText("single execution response") + .toolCalls(new ArrayList()) + .memoryItems(new ArrayList()) + .build(); + } + + @Override + public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { + return create(prompt); + } + } + + private static class NoopToolExecutor implements ToolExecutor { + @Override + public String execute(AgentToolCall call) { + return "noop"; + } + } +} diff --git a/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessCommandGatewayTest.java b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessCommandGatewayTest.java new file mode 100644 index 00000000..044a879c --- /dev/null +++ b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessCommandGatewayTest.java @@ -0,0 +1,867 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.session.AgentSessionMetadata; +import io.github.lnyocly.ai4j.agent.session.AgentSessionSnapshot; +import io.github.lnyocly.ai4j.platform.openai.tool.Tool; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +public class HarnessCommandGatewayTest { + + private Path directory; + + @Before + public void setUp() throws Exception { + directory = Files.createTempDirectory("ai4j-harness-test-"); + } + + @After + public void tearDown() throws Exception { + if (directory != null && Files.exists(directory)) { + Files.walk(directory) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + // Best effort cleanup of the test directory. + } + }); + } + } + + @Test + public void fileStoreRestoresDurableEntitiesAfterReopen() { + FileHarnessStore firstStore = new FileHarnessStore(FileHarnessConfig.builder() + .directory(directory) + .harnessId("file-test") + .build()); + HarnessCommandGateway first = new HarnessCommandGateway( + firstStore, HarnessContract.builder().build(), HarnessActor.agent("test-agent")); + + TaskRecord parent = first.createTask(HarnessTaskSpec.builder() + .taskId("task-parent") + .title("Parent") + .goal("Keep the durable goal") + .plan("Inspect and record") + .build(), HarnessActor.agent("test-agent")); + TaskRecord child = first.createTask(HarnessTaskSpec.builder() + .taskId("task-child") + .title("Child") + .parentTaskId(parent.getTaskId()) + .build(), HarnessActor.agent("test-agent")); + EvidenceRecord evidence = first.recordEvidence(HarnessEvidenceSpec.builder() + .evidenceId("evidence-1") + .taskId(child.getTaskId()) + .kind("test") + .location("unit-test") + .summary("The child was observed") + .build(), HarnessActor.agent("test-agent")); + FactRecord fact = first.recordFact(HarnessFactSpec.builder() + .factId("fact-1") + .taskId(child.getTaskId()) + .statement("The durable store survives a restart") + .source("unit-test") + .evidenceIds(Arrays.asList(evidence.getEvidenceId())) + .build(), HarnessActor.agent("test-agent")); + DecisionRecord decision = first.proposeDecision(HarnessDecisionSpec.builder() + .decisionId("decision-1") + .taskId(child.getTaskId()) + .question("Which state is authoritative?") + .chosenOption("The durable snapshot") + .factIds(Arrays.asList(fact.getFactId())) + .evidenceIds(Arrays.asList(evidence.getEvidenceId())) + .build(), HarnessActor.agent("test-agent")); + ExecutionRecord execution = first.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-1") + .taskId(child.getTaskId()) + .sessionId("session-1") + .runId("run-1") + .inputSummary("restart test") + .build()); + CheckpointRecord checkpoint = first.recordCheckpoint(execution.getExecutionId(), + "checkpointed", null, HarnessActor.agent("test-agent")); + WaitRecord wait = first.ensureWait(execution.getExecutionId(), child.getTaskId(), + "wait-1", WaitType.EXTERNAL_EVENT, "operation-1", "external-1", null); + long version = first.getState().getVersion(); + first.close(); + + HarnessCommandGateway reopened = new HarnessCommandGateway( + new FileHarnessStore(FileHarnessConfig.builder() + .directory(directory) + .harnessId("file-test") + .build()), HarnessContract.builder().build(), HarnessActor.agent("test-agent")); + HarnessState state = reopened.getState(); + + Assert.assertTrue(version > 0L); + Assert.assertEquals(version, state.getVersion()); + Assert.assertNotNull(reopened.getTask(parent.getTaskId())); + Assert.assertEquals(parent.getTaskId(), relationFrom(reopened.getState(), RelationType.PARENT_OF, + parent.getTaskId(), child.getTaskId()).getFromId()); + Assert.assertNotNull(reopened.getState().getFacts().get(fact.getFactId())); + Assert.assertNotNull(reopened.getState().getDecisions().get(decision.getDecisionId())); + Assert.assertNotNull(reopened.getState().getEvidence().get(evidence.getEvidenceId())); + Assert.assertNotNull(reopened.getState().getCheckpoints().get(checkpoint.getCheckpointId())); + Assert.assertEquals(WaitStatus.OPEN, reopened.getWait(wait.getWaitId()).getStatus()); + Assert.assertEquals(ExecutionStatus.READY, + reopened.getExecution(execution.getExecutionId()).getStatus()); + reopened.close(); + } + + @Test + public void jdbcStoreRestoresStateAndIncrementsVersion() { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:ai4j_harness_restart;DB_CLOSE_DELAY=-1"); + + JdbcHarnessStore firstStore = new JdbcHarnessStore(dataSource, "jdbc-test"); + HarnessCommandGateway first = new HarnessCommandGateway(firstStore); + first.createTask(HarnessTaskSpec.builder() + .taskId("jdbc-task") + .title("JDBC task") + .build()); + long version = first.getState().getVersion(); + first.close(); + + JdbcHarnessStore secondStore = new JdbcHarnessStore(dataSource, "jdbc-test"); + HarnessCommandGateway reopened = new HarnessCommandGateway(secondStore); + Assert.assertEquals(version, reopened.getState().getVersion()); + Assert.assertEquals("JDBC task", reopened.getTask("jdbc-task").getTitle()); + reopened.recordEvent("test.event", "jdbc-task", null, HarnessActor.agent("test-agent")); + Assert.assertEquals(version + 1L, reopened.getState().getVersion()); + reopened.close(); + } + + @Test + public void jdbcStoreInitializesOneSharedRowUnderConcurrentStartup() throws Exception { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:ai4j_harness_concurrent_startup;DB_CLOSE_DELAY=-1"); + final CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + Callable openAndLoad = new Callable() { + @Override + public Long call() throws Exception { + start.await(10L, TimeUnit.SECONDS); + JdbcHarnessStore store = new JdbcHarnessStore(dataSource, "concurrent-startup"); + return store.load().getVersion(); + } + }; + try { + Future first = executor.submit(openAndLoad); + Future second = executor.submit(openAndLoad); + start.countDown(); + Assert.assertEquals(0L, first.get(10L, TimeUnit.SECONDS).longValue()); + Assert.assertEquals(0L, second.get(10L, TimeUnit.SECONDS).longValue()); + } finally { + executor.shutdownNow(); + } + } + + @Test + public void sessionRunIdentityIsReusedAndExplicitMismatchIsRejected() { + HarnessCommandGateway gateway = fileGateway("session-run-identity"); + ExecutionRecord first = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-session-first") + .sessionId("stable-session") + .build()); + ExecutionRecord claimed = gateway.claimExecution(first.getExecutionId(), "session-worker", 10_000L); + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + snapshot.setMetadata(new AgentSessionMetadata("stable-session", 1L, 2L, null)); + snapshot.setRunId(claimed.getRunId()); + gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(ExecutionStatus.SUCCEEDED) + .sessionSnapshot(snapshot) + .build()); + + ExecutionRecord second = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-session-second") + .sessionId("stable-session") + .build()); + Assert.assertEquals(claimed.getRunId(), second.getRunId()); + + try { + gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-session-conflict") + .sessionId("stable-session") + .runId("different-run") + .build()); + Assert.fail("an explicit run id must match the existing session identity"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("session snapshot")); + } + gateway.close(); + } + + @Test + public void claimingAnUnboundExecutionCreatesStableSessionLeaseIdentity() { + HarnessCommandGateway gateway = fileGateway("claim-session-binding"); + ExecutionRecord created = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-unbound-session") + .build()); + Assert.assertNull(created.getSessionId()); + + ExecutionRecord claimed = gateway.claimExecution(created.getExecutionId(), "binding-worker", 10_000L); + Assert.assertNotNull(claimed.getSessionId()); + Assert.assertEquals(claimed.getSessionId(), gateway.getExecution(created.getExecutionId()).getSessionId()); + SessionLeaseRecord sessionLease = gateway.getState().getSessionLeases().get(claimed.getSessionId()); + Assert.assertNotNull(sessionLease); + Assert.assertEquals(claimed.getExecutionId(), sessionLease.getExecutionId()); + Assert.assertEquals(claimed.getLeaseId(), sessionLease.getLeaseId()); + + ExecutionRecord completed = gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(ExecutionStatus.SUCCEEDED) + .sessionSnapshot(sessionSnapshot(claimed.getSessionId(), claimed.getRunId())) + .build()); + Assert.assertEquals(ExecutionStatus.SUCCEEDED, completed.getStatus()); + gateway.close(); + } + + @Test + public void corruptFileSnapshotRecoversFromCompleteJournal() throws Exception { + Path storeDirectory = directory.resolve("corrupt-file-snapshot"); + HarnessCommandGateway first = new HarnessCommandGateway( + new FileHarnessStore(FileHarnessConfig.builder() + .directory(storeDirectory) + .harnessId("corrupt-file-snapshot") + .build())); + first.createTask(HarnessTaskSpec.builder() + .taskId("durable-task") + .title("Durable task") + .build()); + first.close(); + + Files.write(storeDirectory.resolve("state.json"), + "{not-json".getBytes(StandardCharsets.UTF_8)); + + HarnessCommandGateway reopened = new HarnessCommandGateway( + new FileHarnessStore(FileHarnessConfig.builder() + .directory(storeDirectory) + .harnessId("corrupt-file-snapshot") + .build())); + Assert.assertEquals("Durable task", reopened.getTask("durable-task").getTitle()); + reopened.close(); + } + + @Test + public void corruptFileSnapshotWithoutRecoverableJournalFailsClosed() throws Exception { + Path storeDirectory = directory.resolve("unrecoverable-file-snapshot"); + Files.createDirectories(storeDirectory); + Files.write(storeDirectory.resolve("state.json"), + "{not-json".getBytes(StandardCharsets.UTF_8)); + + FileHarnessStore store = new FileHarnessStore(FileHarnessConfig.builder() + .directory(storeDirectory) + .harnessId("unrecoverable-file-snapshot") + .build()); + try { + store.load(); + Assert.fail("a corrupt snapshot without journal recovery must fail"); + } catch (HarnessStoreException expected) { + Assert.assertTrue(expected.getMessage().contains("no recoverable journal")); + } finally { + store.close(); + } + } + + @Test + public void jdbcStateDecodeFailureIsWrappedAsHarnessStoreException() throws Exception { + JdbcDataSource dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:ai4j_harness_corrupt;DB_CLOSE_DELAY=-1"); + JdbcHarnessStore store = new JdbcHarnessStore(dataSource, "corrupt-jdbc"); + try (Connection connection = dataSource.getConnection(); + PreparedStatement statement = connection.prepareStatement( + "UPDATE ai4j_harness_state SET state_json = ? WHERE harness_id = ?")) { + statement.setString(1, "{not-json"); + statement.setString(2, "corrupt-jdbc"); + statement.executeUpdate(); + } + try { + store.load(); + Assert.fail("a corrupt JDBC state must fail"); + } catch (HarnessStoreException expected) { + Assert.assertTrue(expected.getMessage().contains("decode JDBC Harness state")); + } finally { + store.close(); + } + } + + @Test + public void managementSchemaExposesRuntimeIdempotencyKey() { + List tools = new HarnessToolRegistry(null).getTools(); + Tool taskTool = null; + for (Object candidate : tools) { + if (candidate instanceof Tool + && HarnessToolNames.TASK_MANAGE.equals(((Tool) candidate).getFunction().getName())) { + taskTool = (Tool) candidate; + break; + } + } + Assert.assertNotNull(taskTool); + Assert.assertNotNull(taskTool.getFunction().getParameters().getProperties().get("idempotencyKey")); + Assert.assertEquals(Arrays.asList("operation"), + taskTool.getFunction().getParameters().getRequired()); + } + + @Test + public void dependencyGraphRejectsCyclesAndBlocksExecutionUntilDone() { + HarnessCommandGateway gateway = fileGateway("dependency-test"); + TaskRecord prerequisite = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-prerequisite") + .title("Prerequisite") + .build()); + TaskRecord dependent = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-dependent") + .title("Dependent") + .build()); + gateway.addDependency(dependent.getTaskId(), prerequisite.getTaskId()); + + try { + gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-blocked") + .taskId(dependent.getTaskId()) + .build()); + Assert.fail("dependent execution should be blocked"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("dependencies")); + } + + finishTask(gateway, prerequisite.getTaskId()); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-ready") + .taskId(dependent.getTaskId()) + .build()); + Assert.assertEquals(ExecutionStatus.READY, execution.getStatus()); + + TaskRecord cycleA = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-cycle-a") + .title("Cycle A") + .build()); + TaskRecord cycleB = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-cycle-b") + .title("Cycle B") + .build()); + gateway.addDependency(cycleA.getTaskId(), cycleB.getTaskId()); + try { + gateway.addDependency(cycleB.getTaskId(), cycleA.getTaskId()); + Assert.fail("dependency cycle should be rejected"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("cycle")); + } + gateway.close(); + } + + @Test + public void blockedTaskRejectsNewExecutionAttachmentClaimAndWait() { + HarnessCommandGateway gateway = fileGateway("blocked-boundary-test"); + TaskRecord task = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-blocked") + .title("Blocked task") + .build()); + ExecutionRecord attached = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-blocked-existing") + .taskId(task.getTaskId()) + .build()); + ExecutionRecord unbound = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-blocked-unbound") + .build()); + gateway.transitionTask(task.getTaskId(), TaskStatus.BLOCKED, + "operator paused the task", HarnessActor.human("operator")); + + try { + gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-blocked-new") + .taskId(task.getTaskId()) + .build()); + Assert.fail("a blocked task must not create a new execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("BLOCKED")); + } + try { + gateway.attachExecutionToTask(unbound.getExecutionId(), task.getTaskId(), + HarnessActor.human("operator")); + Assert.fail("a blocked task must not accept a new execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("BLOCKED")); + } + try { + gateway.claimExecution(attached.getExecutionId(), "blocked-worker", 10_000L); + Assert.fail("a blocked task must not claim an execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("BLOCKED")); + } + try { + gateway.ensureWait(attached.getExecutionId(), task.getTaskId(), "wait-blocked", + WaitType.EXTERNAL_EVENT, "operation-blocked", null, null, + HarnessActor.system("scheduler")); + Assert.fail("a blocked task must not create a new wait"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("BLOCKED")); + } + gateway.close(); + } + + @Test + public void inReviewTaskRejectsNewExecutionAttachmentClaimAndWait() { + HarnessCommandGateway gateway = fileGateway("in-review-boundary-test"); + TaskRecord task = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-in-review") + .title("Task in review") + .build()); + ExecutionRecord attached = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-in-review-existing") + .taskId(task.getTaskId()) + .build()); + ExecutionRecord unbound = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-in-review-unbound") + .build()); + SubmissionRecord submission = gateway.submitTask(task.getTaskId(), attached.getExecutionId(), + HarnessSubmissionSpec.builder().completionClaim("ready for review").build(), + HarnessActor.agent("agent-a")); + Assert.assertEquals(TaskStatus.IN_REVIEW, gateway.getTask(task.getTaskId()).getStatus()); + + try { + gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-in-review-new") + .taskId(task.getTaskId()) + .build()); + Assert.fail("a task in review must not create a new execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("IN_REVIEW")); + } + try { + gateway.attachExecutionToTask(unbound.getExecutionId(), task.getTaskId(), + HarnessActor.human("operator")); + Assert.fail("a task in review must not accept a new execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("IN_REVIEW")); + } + try { + gateway.claimExecution(attached.getExecutionId(), "review-worker", 10_000L); + Assert.fail("a task in review must not claim an execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("IN_REVIEW")); + } + try { + gateway.ensureWait(attached.getExecutionId(), task.getTaskId(), "wait-in-review", + WaitType.EXTERNAL_EVENT, "operation-in-review", null, null, + HarnessActor.system("scheduler")); + Assert.fail("a task in review must not create a new wait"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("IN_REVIEW")); + } + Assert.assertNotNull(submission); + gateway.close(); + } + + @Test + public void completionRechecksDependenciesAddedAfterExecution() { + HarnessCommandGateway gateway = fileGateway("completion-dependency-test"); + TaskRecord task = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-completion-dependent") + .title("Completion dependency") + .build()); + ExecutionRecord execution = successfulExecution(gateway, task.getTaskId()); + SubmissionRecord submission = gateway.submitTask(task.getTaskId(), execution.getExecutionId(), + HarnessSubmissionSpec.builder().completionClaim("complete").build(), + HarnessActor.agent("agent-a")); + gateway.reviewSubmission(submission.getSubmissionId(), ReviewVerdict.APPROVED, + null, null, HarnessActor.human("reviewer")); + TaskRecord dependency = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-added-late") + .title("Added dependency") + .build()); + gateway.addDependency(task.getTaskId(), dependency.getTaskId(), HarnessActor.human("operator")); + + try { + gateway.completeTask(task.getTaskId(), submission.getSubmissionId(), + HarnessActor.human("reviewer")); + Assert.fail("completion must recheck dependencies added after execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("dependencies")); + } + Assert.assertEquals(TaskStatus.IN_REVIEW, gateway.getTask(task.getTaskId()).getStatus()); + gateway.close(); + } + + @Test + public void cancelledRunningExecutionQuarantinesLateFailedAndUnknownOutcomes() { + HarnessCommandGateway gateway = fileGateway("cancelled-running-outcome-test"); + ExecutionStatus[] lateStatuses = {ExecutionStatus.FAILED, ExecutionStatus.UNKNOWN}; + for (ExecutionStatus lateStatus : lateStatuses) { + String suffix = lateStatus.name().toLowerCase(); + TaskRecord task = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-cancelled-" + suffix) + .title("Cancelled " + suffix) + .build()); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-cancelled-" + suffix) + .taskId(task.getTaskId()) + .build()); + ExecutionRecord claimed = gateway.claimExecution(execution.getExecutionId(), + "worker-" + suffix, 10_000L); + gateway.transitionTask(task.getTaskId(), TaskStatus.CANCELLED, + "human handoff", HarnessActor.human("operator")); + + ExecutionRecord persisted = gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(lateStatus) + .error("late worker result") + .build()); + Assert.assertEquals(ExecutionStatus.CANCELLED, persisted.getStatus()); + Assert.assertEquals(TaskStatus.CANCELLED, gateway.getTask(task.getTaskId()).getStatus()); + Assert.assertTrue(persisted.getError().contains("late outcome was " + lateStatus.name())); + } + boolean quarantinedEventFound = false; + for (HarnessEventRecord event : gateway.getState().getEvents()) { + if (event != null && "execution.outcome_quarantined".equals(event.getType())) { + quarantinedEventFound = true; + Assert.assertTrue(event.getActorId().startsWith("worker:")); + } + } + Assert.assertTrue("late outcomes must be recorded as quarantined", quarantinedEventFound); + gateway.close(); + } + + @Test + public void leaseFencingAndExpiryRequireReconciliation() throws Exception { + HarnessCommandGateway gateway = fileGateway("lease-test"); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-lease") + .build()); + ExecutionRecord claimed = gateway.claimExecution(execution.getExecutionId(), "worker-a", 20L); + + try { + gateway.heartbeat(execution.getExecutionId(), claimed.getLeaseId(), + claimed.getFencingToken() + 1L, "worker-a", 20L); + Assert.fail("stale fencing token should be rejected"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("lease")); + } + + Thread.sleep(50L); + try { + gateway.claimExecution(execution.getExecutionId(), "worker-b", 1000L); + Assert.fail("expired execution should require reconciliation"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("UNKNOWN")); + } + Assert.assertEquals(ExecutionStatus.UNKNOWN, + gateway.getExecution(execution.getExecutionId()).getStatus()); + + try { + gateway.reconcileExecution(execution.getExecutionId(), ExecutionStatus.READY, + "agent cannot establish the external outcome", HarnessActor.agent("test-agent")); + Assert.fail("an Agent must not reconcile an expired execution"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("not allowed")); + } + + ExecutionRecord reconciled = gateway.reconcileExecution(execution.getExecutionId(), + ExecutionStatus.READY, "operator confirmed no side effect", HarnessActor.human("operator")); + Assert.assertEquals(ExecutionStatus.READY, reconciled.getStatus()); + ExecutionRecord reclaimed = gateway.claimExecution(execution.getExecutionId(), "worker-b", 1000L); + Assert.assertEquals(ExecutionStatus.RUNNING, reclaimed.getStatus()); + gateway.releaseExecution(reclaimed.getExecutionId(), reclaimed.getLeaseId(), + reclaimed.getFencingToken(), "worker-b"); + gateway.close(); + } + + @Test + public void oldWorkerLeaseCannotWriteAfterAnotherWorkerReclaimsExecution() { + HarnessCommandGateway gateway = fileGateway("worker-reclaim-test"); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-reclaim") + .build()); + ExecutionRecord first = gateway.claimExecution(execution.getExecutionId(), "worker-a", 10_000L); + gateway.releaseExecution(first.getExecutionId(), first.getLeaseId(), + first.getFencingToken(), "worker-a"); + ExecutionRecord second = gateway.claimExecution(execution.getExecutionId(), "worker-b", 10_000L); + Assert.assertTrue(second.getFencingToken() > first.getFencingToken()); + + try { + gateway.heartbeat(first.getExecutionId(), first.getLeaseId(), + first.getFencingToken(), "worker-a", 10_000L); + Assert.fail("the old worker must not heartbeat after reclaim"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("lease")); + } + try { + gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(first.getExecutionId()) + .leaseId(first.getLeaseId()) + .fencingToken(first.getFencingToken()) + .status(ExecutionStatus.SUCCEEDED) + .outputText("stale result") + .build()); + Assert.fail("the old worker must not persist an outcome after reclaim"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("lease")); + } + gateway.releaseExecution(second.getExecutionId(), second.getLeaseId(), + second.getFencingToken(), "worker-b"); + gateway.close(); + } + + @Test + public void userApprovalAndAsyncWaitsSurviveFileReopenAndDelivery() { + HarnessCommandGateway first = fileGateway("wait-recovery-test"); + String[] executionIds = {"execution-user-input", "execution-approval", "execution-async"}; + WaitType[] waitTypes = {WaitType.USER_INPUT, WaitType.APPROVAL, WaitType.ASYNC_OPERATION}; + String[] waitIds = {"wait-user-input", "wait-approval", "wait-async"}; + for (int i = 0; i < waitTypes.length; i++) { + ExecutionRecord execution = first.createExecution(HarnessExecutionSpec.builder() + .executionId(executionIds[i]) + .build()); + first.ensureWait(execution.getExecutionId(), null, waitIds[i], waitTypes[i], + "operation-" + i, "external-" + i, null); + } + first.close(); + + HarnessCommandGateway reopened = fileGateway("wait-recovery-test"); + for (int i = 0; i < waitTypes.length; i++) { + WaitRecord wait = reopened.getWait(waitIds[i]); + Assert.assertEquals(WaitStatus.OPEN, wait.getStatus()); + Assert.assertEquals(waitTypes[i], wait.getType()); + reopened.deliverWait(wait.getWaitId(), "delivery-" + i, + HarnessActor.system("recovery-worker")); + Assert.assertEquals(WaitStatus.DELIVERED, + reopened.getWait(wait.getWaitId()).getStatus()); + Assert.assertEquals(ExecutionStatus.READY, + reopened.getExecution(executionIds[i]).getStatus()); + } + reopened.close(); + } + + @Test + public void deliveredApprovalMatchesToolAndArgumentsButNotAnotherInvocation() { + HarnessCommandGateway gateway = fileGateway("approval-match-test"); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-approval") + .build()); + WaitRecord wait = gateway.requestApproval(execution.getExecutionId(), null, + "write_file", "call-a", "{\"path\":\"a.txt\",\"content\":\"x\"}"); + gateway.deliverWait(wait.getWaitId(), Boolean.TRUE, HarnessActor.human("reviewer")); + + Assert.assertTrue(gateway.isApprovalGranted(execution.getExecutionId(), "write_file", + "call-a", "{\"path\":\"a.txt\",\"content\":\"x\"}")); + Assert.assertTrue("provider retries may assign a new call id", + gateway.isApprovalGranted(execution.getExecutionId(), "write_file", "call-b", + "{\"content\":\"x\",\"path\":\"a.txt\"}")); + Assert.assertFalse("approval must not transfer to a different argument set", + gateway.isApprovalGranted(execution.getExecutionId(), "write_file", "call-b", + "{\"path\":\"b.txt\",\"content\":\"x\"}")); + Assert.assertFalse("approval must not transfer to another tool", + gateway.isApprovalGranted(execution.getExecutionId(), "delete_file", "call-b", + "{\"path\":\"a.txt\"}")); + gateway.close(); + } + + @Test + public void submissionReviewAndGateKeepAgentOutsideCompletionBoundary() { + HarnessContract contract = HarnessContract.builder() + .completionGate(new HarnessGate() { + @Override + public String getName() { + return "required-evidence"; + } + + @Override + public GateResult evaluate(TaskRecord task, + SubmissionRecord submission, + HarnessState state) { + return submission != null && submission.getEvidenceIds() != null + && !submission.getEvidenceIds().isEmpty() + ? GateResult.pass(getName()) + : GateResult.fail(getName(), "evidence is required"); + } + }) + .build(); + HarnessCommandGateway gateway = new HarnessCommandGateway( + new FileHarnessStore(FileHarnessConfig.builder().directory(directory).build()), + contract, HarnessActor.agent("agent-a")); + TaskRecord task = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-review") + .title("Review boundary") + .build(), HarnessActor.agent("agent-a")); + ExecutionRecord execution = successfulExecution(gateway, task.getTaskId()); + EvidenceRecord evidence = gateway.recordEvidence(HarnessEvidenceSpec.builder() + .evidenceId("evidence-review") + .taskId(task.getTaskId()) + .kind("test") + .summary("review evidence") + .build(), HarnessActor.agent("agent-a")); + SubmissionRecord submission = gateway.submitTask(task.getTaskId(), execution.getExecutionId(), + HarnessSubmissionSpec.builder() + .completionClaim("agent claim") + .evidenceIds(Arrays.asList(evidence.getEvidenceId())) + .build(), HarnessActor.agent("agent-a")); + + try { + gateway.reviewSubmission(submission.getSubmissionId(), ReviewVerdict.APPROVED, + null, null, HarnessActor.agent("agent-a")); + Assert.fail("Agent must not review its own submission"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("allowed")); + } + ReviewRecord review = gateway.reviewSubmission(submission.getSubmissionId(), + ReviewVerdict.APPROVED, "looks good", null, HarnessActor.human("reviewer")); + Assert.assertEquals(ReviewVerdict.APPROVED, review.getVerdict()); + try { + gateway.completeTask(task.getTaskId(), submission.getSubmissionId(), + HarnessActor.agent("agent-a")); + Assert.fail("Agent must not complete a task"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("allowed")); + } + TaskRecord completed = gateway.completeTask(task.getTaskId(), + submission.getSubmissionId(), HarnessActor.human("reviewer")); + Assert.assertEquals(TaskStatus.DONE, completed.getStatus()); + Assert.assertFalse(gateway.getState().getGates().isEmpty()); + gateway.close(); + } + + @Test + public void completionRejectsSubmissionWithoutSuccessfulExecution() { + HarnessCommandGateway gateway = fileGateway("completion-execution-boundary"); + TaskRecord task = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-no-execution") + .title("Execution is required") + .build()); + SubmissionRecord submission = gateway.submitTask(task.getTaskId(), null, + HarnessSubmissionSpec.builder().completionClaim("claim without execution").build()); + gateway.reviewSubmission(submission.getSubmissionId(), ReviewVerdict.APPROVED, + null, null, HarnessActor.human("reviewer")); + + try { + gateway.completeTask(task.getTaskId(), submission.getSubmissionId(), HarnessActor.human("reviewer")); + Assert.fail("completion must require a submission execution"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("reference an execution")); + } + + TaskRecord unfinishedTask = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-unfinished-execution") + .title("Unfinished execution task") + .build()); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-not-finished") + .taskId(unfinishedTask.getTaskId()) + .build()); + SubmissionRecord unfinished = gateway.submitTask(unfinishedTask.getTaskId(), execution.getExecutionId(), + HarnessSubmissionSpec.builder().completionClaim("claim before execution finishes").build()); + gateway.reviewSubmission(unfinished.getSubmissionId(), ReviewVerdict.APPROVED, + null, null, HarnessActor.human("reviewer")); + try { + gateway.completeTask(unfinishedTask.getTaskId(), unfinished.getSubmissionId(), HarnessActor.human("reviewer")); + Assert.fail("completion must require a successful execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("SUCCEEDED")); + } + gateway.close(); + } + + @Test + public void atomicDynamicTaskCreationDoesNotLeaveOrphanWhenAttachmentFails() { + HarnessCommandGateway gateway = fileGateway("atomic-task-test"); + TaskRecord existing = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-existing") + .title("Existing") + .build()); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-attached") + .taskId(existing.getTaskId()) + .build()); + + try { + gateway.createTaskAndAttachExecution(HarnessTaskSpec.builder() + .taskId("task-orphan") + .title("Should roll back") + .build(), execution.getExecutionId(), HarnessActor.agent("agent-a")); + Assert.fail("an execution cannot be attached to a second task"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("another task")); + } + Assert.assertNull(gateway.getTask("task-orphan")); + gateway.close(); + } + + private HarnessCommandGateway fileGateway(String harnessId) { + return new HarnessCommandGateway( + new FileHarnessStore(FileHarnessConfig.builder() + .directory(directory.resolve(harnessId)) + .harnessId(harnessId) + .build()), HarnessContract.builder().build(), HarnessActor.agent("test-agent")); + } + + private void finishTask(HarnessCommandGateway gateway, String taskId) { + ExecutionRecord execution = successfulExecution(gateway, taskId); + SubmissionRecord submission = gateway.submitTask(taskId, execution.getExecutionId(), + HarnessSubmissionSpec.builder().completionClaim("done").build(), + HarnessActor.agent("test-agent")); + gateway.reviewSubmission(submission.getSubmissionId(), ReviewVerdict.APPROVED, + null, null, HarnessActor.human("reviewer")); + gateway.completeTask(taskId, submission.getSubmissionId(), HarnessActor.human("reviewer")); + } + + private ExecutionRecord successfulExecution(HarnessCommandGateway gateway, String taskId) { + ExecutionRecord created = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId(taskId + "-execution") + .taskId(taskId) + .build()); + ExecutionRecord claimed = gateway.claimExecution(created.getExecutionId(), + "worker-" + taskId, 10_000L); + return gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(ExecutionStatus.SUCCEEDED) + .outputText("successful test execution") + .build()); + } + + private RelationRecord relationFrom(HarnessState state, + RelationType type, + String from, + String to) { + for (RelationRecord relation : state.getRelations().values()) { + if (relation != null && relation.getType() == type + && from.equals(relation.getFromId()) && to.equals(relation.getToId())) { + return relation; + } + } + Assert.fail("relation not found"); + return null; + } + + private AgentSessionSnapshot sessionSnapshot(String sessionId, String runId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + snapshot.setMetadata(new AgentSessionMetadata(sessionId, 1L, 2L, null)); + snapshot.setRunId(runId); + return snapshot; + } +} diff --git a/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessGatewayInvariantTest.java b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessGatewayInvariantTest.java new file mode 100644 index 00000000..6b4a0fde --- /dev/null +++ b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessGatewayInvariantTest.java @@ -0,0 +1,380 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.session.AgentSessionMetadata; +import io.github.lnyocly.ai4j.agent.session.AgentSessionSnapshot; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Comparator; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +/** Regression coverage for cross-entity and cross-worker Harness invariants. */ +public class HarnessGatewayInvariantTest { + + private Path directory; + + @Before + public void setUp() throws Exception { + directory = Files.createTempDirectory("ai4j-harness-invariant-test-"); + } + + @After + public void tearDown() throws Exception { + if (directory != null && Files.exists(directory)) { + Files.walk(directory) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + // Best effort cleanup of the test directory. + } + }); + } + } + + @Test + public void idempotencyIsNamespacedByEntityAndScope() { + HarnessCommandGateway gateway = gateway("idempotency-namespaces"); + TaskRecord shopA = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-shop-a") + .scopeKey("shop-a") + .title("Shop A task") + .idempotencyKey("same-logical-key") + .build()); + TaskRecord shopAReplay = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-shop-a-retry") + .scopeKey("shop-a") + .title("Retry should observe the first task") + .idempotencyKey("same-logical-key") + .build()); + TaskRecord shopB = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-shop-b") + .scopeKey("shop-b") + .title("Shop B task") + .idempotencyKey("same-logical-key") + .build()); + + Assert.assertEquals(shopA.getTaskId(), shopAReplay.getTaskId()); + Assert.assertNotEquals(shopA.getTaskId(), shopB.getTaskId()); + + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-shop-a") + .scopeKey("shop-a") + .sessionId("session-shop-a") + .idempotencyKey("same-logical-key") + .build()); + ExecutionRecord executionReplay = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-shop-a-retry") + .scopeKey("shop-a") + .sessionId("session-shop-a") + .idempotencyKey("same-logical-key") + .build()); + + Assert.assertNotEquals(shopA.getTaskId(), execution.getExecutionId()); + Assert.assertEquals(execution.getExecutionId(), executionReplay.getExecutionId()); + gateway.close(); + } + + @Test + public void legacyRawIdempotencyIsReadOnlyWithinMatchingTypeAndScope() { + HarnessCommandGateway gateway = gateway("legacy-idempotency"); + TaskRecord legacyTask = gateway.createTask(HarnessTaskSpec.builder() + .taskId("legacy-task") + .scopeKey("legacy-scope") + .title("Legacy task") + .build()); + gateway.getStore().update(new HarnessStateMutation() { + @Override + public HarnessState apply(HarnessState state) { + state.getIdempotency().put("legacy-key", legacyTask.getTaskId()); + return state; + } + }); + + TaskRecord replay = gateway.createTask(HarnessTaskSpec.builder() + .taskId("new-task-id") + .scopeKey("legacy-scope") + .title("Replay of legacy task") + .idempotencyKey("legacy-key") + .build()); + TaskRecord otherScope = gateway.createTask(HarnessTaskSpec.builder() + .taskId("other-scope-task") + .scopeKey("other-scope") + .title("Same raw key in another scope") + .idempotencyKey("legacy-key") + .build()); + + Assert.assertEquals(legacyTask.getTaskId(), replay.getTaskId()); + Assert.assertNotEquals(legacyTask.getTaskId(), otherScope.getTaskId()); + gateway.close(); + } + + @Test + public void graphReferencesRequireExistingEntitiesAndMatchingScopes() { + HarnessCommandGateway gateway = gateway("reference-invariants"); + TaskRecord taskA = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-a") + .scopeKey("scope-a") + .title("Task A") + .build()); + TaskRecord taskB = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-b") + .scopeKey("scope-b") + .title("Task B") + .build()); + EvidenceRecord evidenceA = gateway.recordEvidence(HarnessEvidenceSpec.builder() + .evidenceId("evidence-a") + .taskId(taskA.getTaskId()) + .kind("test") + .summary("Evidence for task A") + .build()); + EvidenceRecord evidenceB = gateway.recordEvidence(HarnessEvidenceSpec.builder() + .evidenceId("evidence-b") + .taskId(taskB.getTaskId()) + .kind("test") + .summary("Evidence for task B") + .build()); + + try { + gateway.recordFact(HarnessFactSpec.builder() + .factId("fact-missing-evidence") + .taskId(taskA.getTaskId()) + .statement("This reference must fail") + .evidenceIds(Collections.singletonList("missing-evidence")) + .build()); + Assert.fail("a Fact must not reference missing Evidence"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("evidence not found")); + } + try { + gateway.recordFact(HarnessFactSpec.builder() + .factId("fact-cross-scope") + .taskId(taskA.getTaskId()) + .statement("This scope must fail") + .evidenceIds(Collections.singletonList(evidenceB.getEvidenceId())) + .build()); + Assert.fail("a Fact must not reference Evidence from another scope"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("scope")); + } + + FactRecord factA = gateway.recordFact(HarnessFactSpec.builder() + .factId("fact-a") + .taskId(taskA.getTaskId()) + .statement("Task A is supported") + .evidenceIds(Collections.singletonList(evidenceA.getEvidenceId())) + .build()); + FactRecord factB = gateway.recordFact(HarnessFactSpec.builder() + .factId("fact-b") + .taskId(taskB.getTaskId()) + .statement("Task B is supported") + .evidenceIds(Collections.singletonList(evidenceB.getEvidenceId())) + .build()); + try { + gateway.proposeDecision(HarnessDecisionSpec.builder() + .decisionId("decision-missing-fact") + .taskId(taskA.getTaskId()) + .question("Which fact exists?") + .factIds(Collections.singletonList("missing-fact")) + .build()); + Assert.fail("a Decision must not reference a missing Fact"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("fact not found")); + } + try { + gateway.proposeDecision(HarnessDecisionSpec.builder() + .decisionId("decision-cross-scope") + .taskId(taskA.getTaskId()) + .question("Which scope is valid?") + .factIds(Collections.singletonList(factB.getFactId())) + .build()); + Assert.fail("a Decision must not reference a Fact from another scope"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("scope")); + } + DecisionRecord decision = gateway.proposeDecision(HarnessDecisionSpec.builder() + .decisionId("decision-a") + .taskId(taskA.getTaskId()) + .question("Which evidence supports Task A?") + .chosenOption("evidence-a") + .factIds(Collections.singletonList(factA.getFactId())) + .evidenceIds(Collections.singletonList(evidenceA.getEvidenceId())) + .build()); + Assert.assertEquals("scope-a", decision.getScopeKey()); + gateway.close(); + } + + @Test + public void evidenceTaskExecutionAndScopeMustAgree() { + HarnessCommandGateway gateway = gateway("evidence-invariants"); + TaskRecord taskA = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-evidence-a") + .scopeKey("scope-a") + .title("Task A") + .build()); + TaskRecord taskB = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-evidence-b") + .scopeKey("scope-b") + .title("Task B") + .build()); + ExecutionRecord executionA = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-evidence-a") + .taskId(taskA.getTaskId()) + .build()); + EvidenceRecord valid = gateway.recordEvidence(HarnessEvidenceSpec.builder() + .evidenceId("evidence-valid") + .taskId(taskA.getTaskId()) + .executionId(executionA.getExecutionId()) + .kind("test") + .summary("Consistent task and execution") + .build()); + Assert.assertEquals("scope-a", valid.getScopeKey()); + + try { + gateway.recordEvidence(HarnessEvidenceSpec.builder() + .evidenceId("evidence-wrong-task") + .taskId(taskB.getTaskId()) + .executionId(executionA.getExecutionId()) + .kind("test") + .summary("Mismatched task") + .build()); + Assert.fail("Evidence task and execution task must agree"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("tasks")); + } + try { + gateway.recordEvidence(HarnessEvidenceSpec.builder() + .evidenceId("evidence-wrong-scope") + .scopeKey("scope-b") + .taskId(taskA.getTaskId()) + .executionId(executionA.getExecutionId()) + .kind("test") + .summary("Mismatched scope") + .build()); + Assert.fail("Evidence scope must agree with its task and execution"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("scope")); + } + gateway.close(); + } + + @Test + public void approvalWaitCreationIsAtomicAndIdempotentAcrossWorkers() throws Exception { + final HarnessCommandGateway gateway = gateway("approval-race"); + final ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-approval-race") + .build()); + final CountDownLatch start = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(2); + Callable request = new Callable() { + @Override + public WaitRecord call() throws Exception { + start.await(); + return gateway.requestApproval(execution.getExecutionId(), null, + "write_file", "same-call", "{\"path\":\"a.txt\"}", + HarnessActor.agent("agent-worker")); + } + }; + try { + Future first = workers.submit(request); + Future second = workers.submit(request); + start.countDown(); + WaitRecord firstWait = first.get(); + WaitRecord secondWait = second.get(); + Assert.assertEquals(firstWait.getWaitId(), secondWait.getWaitId()); + Assert.assertEquals(1, gateway.listOpenWaits(execution.getExecutionId()).size()); + } finally { + workers.shutdownNow(); + gateway.close(); + } + } + + @Test + public void executionOutcomeRejectsInvalidStatusAndSnapshotBeforeWriting() { + HarnessCommandGateway gateway = gateway("outcome-invariants"); + ExecutionRecord created = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-outcome") + .sessionId("session-outcome") + .runId("run-outcome") + .build()); + ExecutionRecord claimed = gateway.claimExecution(created.getExecutionId(), + "outcome-worker", 10_000L); + + try { + gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(ExecutionStatus.RUNNING) + .build()); + Assert.fail("RUNNING must not be persisted as an outcome"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("not a persistable")); + } + try { + gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(ExecutionStatus.SUCCEEDED) + .waitId("wait-not-allowed") + .build()); + Assert.fail("a terminal outcome must not carry a wait"); + } catch (HarnessValidationException expected) { + Assert.assertTrue(expected.getMessage().contains("only a WAITING")); + } + + AgentSessionSnapshot wrongSnapshot = sessionSnapshot("other-session", "run-outcome"); + try { + gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(ExecutionStatus.SUCCEEDED) + .sessionSnapshot(wrongSnapshot) + .build()); + Assert.fail("an outcome snapshot must belong to the execution session"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("session snapshot")); + } + Assert.assertEquals(ExecutionStatus.RUNNING, + gateway.getExecution(claimed.getExecutionId()).getStatus()); + + ExecutionRecord completed = gateway.persistExecutionOutcome(HarnessExecutionOutcome.builder() + .executionId(claimed.getExecutionId()) + .leaseId(claimed.getLeaseId()) + .fencingToken(claimed.getFencingToken()) + .status(ExecutionStatus.SUCCEEDED) + .sessionSnapshot(sessionSnapshot("session-outcome", "run-outcome")) + .build()); + Assert.assertEquals(ExecutionStatus.SUCCEEDED, completed.getStatus()); + gateway.close(); + } + + private HarnessCommandGateway gateway(String harnessId) { + return new HarnessCommandGateway( + HarnessPersistence.file(FileHarnessConfig.builder() + .directory(directory.resolve(harnessId)) + .harnessId(harnessId) + .build()).getStore(), + HarnessContract.builder().build(), + HarnessActor.agent("test-agent")); + } + + private AgentSessionSnapshot sessionSnapshot(String sessionId, String runId) { + AgentSessionSnapshot snapshot = new AgentSessionSnapshot(); + snapshot.setMetadata(new AgentSessionMetadata(sessionId, 1L, 2L, null)); + snapshot.setRunId(runId); + return snapshot; + } +} diff --git a/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessManagementToolExecutorTest.java b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessManagementToolExecutorTest.java new file mode 100644 index 00000000..838aa5cc --- /dev/null +++ b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessManagementToolExecutorTest.java @@ -0,0 +1,221 @@ +package io.github.lnyocly.ai4j.harness; + +import com.alibaba.fastjson2.JSON; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; + +public class HarnessManagementToolExecutorTest { + + private Path directory; + + @Before + public void setUp() throws Exception { + directory = Files.createTempDirectory("ai4j-harness-management-test-"); + } + + @After + public void tearDown() throws Exception { + if (directory != null && Files.exists(directory)) { + Files.walk(directory) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + // Best effort cleanup of the test directory. + } + }); + } + } + + @Test + public void managementCommandsInheritScopeAndCannotReadAnotherScope() throws Exception { + HarnessCommandGateway gateway = gateway("scope-management"); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-shop-a") + .scopeKey("shop-a") + .sessionId("customer-session-a") + .build()); + HarnessExecutionContext context = new HarnessExecutionContext( + gateway, execution.getExecutionId(), null, execution.getSessionId(), + execution.getScopeKey(), execution.getRunId(), HarnessActor.agent("support-agent"), null); + HarnessManagementToolExecutor tools = new HarnessManagementToolExecutor(context); + + TaskRecord first = JSON.parseObject(result(tools, "task-create-shop-a-1", + HarnessToolNames.TASK_MANAGE, + "{\"operation\":\"create\",\"taskId\":\"task-shop-a-1\"," + + "\"title\":\"Investigate refund\"}"), TaskRecord.class); + Assert.assertEquals("shop-a", first.getScopeKey()); + Assert.assertEquals(first.getTaskId(), context.getTaskId()); + + TaskRecord second = JSON.parseObject(result(tools, "task-create-shop-a-2", + HarnessToolNames.TASK_MANAGE, + "{\"operation\":\"create\",\"taskId\":\"task-shop-a-2\"," + + "\"title\":\"Check replacement\"}"), TaskRecord.class); + Assert.assertEquals("shop-a", second.getScopeKey()); + + TaskRecord foreign = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-shop-b") + .scopeKey("shop-b") + .title("Foreign task") + .build()); + Assert.assertNotNull(foreign); + + String view = result(tools, HarnessToolNames.CONTEXT_GET, "{}"); + Assert.assertTrue(view.contains("task-shop-a-1")); + Assert.assertTrue(view.contains("task-shop-a-2")); + Assert.assertFalse(view.contains("task-shop-b")); + + try { + result(tools, HarnessToolNames.TASK_MANAGE, + "{\"operation\":\"get\",\"taskId\":\"task-shop-b\"}"); + Assert.fail("a scoped Agent must not read another scope"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("outside")); + } + + try { + result(tools, HarnessToolNames.TASK_MANAGE, + "{\"operation\":\"create\",\"scopeKey\":\"shop-b\"," + + "\"title\":\"Cross-scope task\"}"); + Assert.fail("a scoped Agent must not choose another write scope"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("outside")); + } + gateway.close(); + } + + @Test + public void relationManagementUsesGatewayValidationAndScopeProjection() throws Exception { + HarnessCommandGateway gateway = gateway("relation-management"); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-relation") + .scopeKey("project-a") + .build()); + HarnessExecutionContext context = new HarnessExecutionContext( + gateway, execution.getExecutionId(), null, null, + execution.getScopeKey(), execution.getRunId(), HarnessActor.agent("coding-agent"), null); + HarnessManagementToolExecutor tools = new HarnessManagementToolExecutor(context); + TaskRecord from = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-from") + .scopeKey("project-a") + .title("Source") + .build()); + TaskRecord to = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-to") + .scopeKey("project-a") + .title("Target") + .build()); + + RelationRecord relation = JSON.parseObject(result(tools, HarnessToolNames.RELATION_MANAGE, + "{\"operation\":\"create\",\"type\":\"SUPPORTS\"," + + "\"fromKind\":\"TASK\",\"fromId\":\"task-from\"," + + "\"toKind\":\"TASK\",\"toId\":\"task-to\"}"), RelationRecord.class); + Assert.assertEquals(RelationType.SUPPORTS, relation.getType()); + Assert.assertEquals("project-a", relation.getScopeKey()); + Assert.assertEquals(1, gateway.listRelationsInScope("project-a").size()); + Assert.assertEquals(0, gateway.listRelationsInScope("project-b").size()); + + String listed = result(tools, HarnessToolNames.RELATION_MANAGE, + "{\"operation\":\"list\"}"); + Assert.assertTrue(listed.contains(relation.getRelationId())); + String fetched = result(tools, HarnessToolNames.RELATION_MANAGE, + "{\"operation\":\"get\",\"relationId\":\"" + + relation.getRelationId() + "\"}"); + Assert.assertTrue(fetched.contains(relation.getRelationId())); + + TaskRecord foreign = gateway.createTask(HarnessTaskSpec.builder() + .taskId("task-foreign") + .scopeKey("project-b") + .title("Foreign") + .build()); + try { + result(tools, HarnessToolNames.RELATION_MANAGE, + "{\"operation\":\"add\",\"type\":\"SUPPORTS\"," + + "\"fromKind\":\"TASK\",\"fromId\":\"" + + from.getTaskId() + "\",\"toKind\":\"TASK\",\"toId\":\"" + + foreign.getTaskId() + "\"}"); + Assert.fail("relation endpoints from different scopes must be rejected"); + } catch (HarnessConflictException expected) { + Assert.assertTrue(expected.getMessage().contains("scope")); + } + gateway.close(); + } + + @Test + public void repeatedTaskCreateCallIsIdempotentWithoutExplicitKey() throws Exception { + HarnessCommandGateway gateway = gateway("task-create-idempotency"); + gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-1") + .scopeKey("project-a") + .sessionId("session-1") + .runId("run-1") + .build()); + HarnessExecutionContext context = new HarnessExecutionContext( + gateway, "execution-1", null, "session-1", "project-a", "run-1", + HarnessActor.agent("test-agent"), null); + HarnessManagementToolExecutor tools = new HarnessManagementToolExecutor(context); + AgentToolCall call = AgentToolCall.builder() + .name(HarnessToolNames.TASK_MANAGE) + .callId("task-create-call") + .arguments("{\"operation\":\"create\",\"taskId\":\"first-task\"," + + "\"title\":\"Discovered work\"}") + .type("function_call") + .build(); + + TaskRecord first = JSON.parseObject(result(tools, call), TaskRecord.class); + TaskRecord repeated = JSON.parseObject(result(tools, call), TaskRecord.class); + + Assert.assertEquals(first.getTaskId(), repeated.getTaskId()); + Assert.assertEquals(1, gateway.listTasks("project-a").size()); + gateway.close(); + } + + private HarnessCommandGateway gateway(String harnessId) { + return new HarnessCommandGateway( + HarnessPersistence.file(FileHarnessConfig.builder() + .directory(directory.resolve(harnessId)) + .harnessId(harnessId) + .build()).getStore(), + HarnessContract.builder().build(), HarnessActor.agent("test-agent")); + } + + private String result(HarnessManagementToolExecutor tools, + String name, + String arguments) throws Exception { + return result(tools, AgentToolCall.builder() + .name(name) + .callId(name + "-call") + .arguments(arguments) + .type("function_call") + .build()); + } + + private String result(HarnessManagementToolExecutor tools, + String callId, + String name, + String arguments) throws Exception { + return result(tools, AgentToolCall.builder() + .name(name) + .callId(callId) + .arguments(arguments) + .type("function_call") + .build()); + } + + private String result(HarnessManagementToolExecutor tools, + AgentToolCall call) throws Exception { + AgentToolExecution execution = tools.start(call); + AgentToolResult result = execution == null ? null : execution.await(); + return result == null ? null : result.getOutput(); + } +} diff --git a/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessReliabilityLab.java b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessReliabilityLab.java new file mode 100644 index 00000000..d9003862 --- /dev/null +++ b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessReliabilityLab.java @@ -0,0 +1,234 @@ +package io.github.lnyocly.ai4j.harness; + +import java.io.BufferedWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.TreeSet; + +/** + * Adversarial reliability lab for {@link FileHarnessStore}. Not a CI test: it + * is a standalone driver for manual experiments, kept under src/test so it + * ships with the module but never runs in the default suite. + * + * Subcommands: + * writer <dir> <harnessId> <updates> <pauseMs> <haltAfterMs> + * Applies <updates> mutations (one task per update), pausing + * <pauseMs> between updates, and hard-kills the JVM (Runtime.halt, + * the kill -9 analog: no shutdown hooks, no cleanup) after + * <haltAfterMs> milliseconds. Prints one "COMMITTED <taskId>" + * line per successful update, flushed immediately — so a marker on + * disk means update() had returned. + * verify <dir> <harnessId> <markerFile> + * Reloads the store after a crash and asserts every COMMITTED marker + * survived. Prints "VERIFY OK|MISSING ..." and exits 0/3; exit 2 on + * recovery failure (store did not load). + * scale <dir> <harnessId> <tasks> <payloadBytes> <compactionBytes> + * Builds <tasks> tasks with a payload of <payloadBytes> bytes, + * reports per-update latency percentiles, state/journal sizes, and + * load() (replay) timings. + */ +public final class HarnessReliabilityLab { + + private HarnessReliabilityLab() { + } + + public static void main(String[] args) throws Exception { + if (args.length == 0) { + usage(); + System.exit(64); + } + String command = args[0]; + if ("writer".equals(command)) { + writer(dir(args[1]), args[2], Integer.parseInt(args[3]), Long.parseLong(args[4]), Long.parseLong(args[5])); + } else if ("verify".equals(command)) { + verify(dir(args[1]), args[2], Paths.get(args[3])); + } else if ("scale".equals(command)) { + scale(dir(args[1]), args[2], Integer.parseInt(args[3]), Integer.parseInt(args[4]), Long.parseLong(args[5])); + } else { + usage(); + System.exit(64); + } + } + + private static Path dir(String value) { + return Paths.get(value).toAbsolutePath().normalize(); + } + + private static void usage() { + System.err.println("usage: HarnessReliabilityLab writer|verify|scale ..."); + } + + private static FileHarnessStore store(Path directory, String harnessId, long compactionBytes) { + FileHarnessConfig config = FileHarnessConfig.builder() + .directory(directory) + .harnessId(harnessId) + .journalCompactionBytes(compactionBytes) + .build(); + return new FileHarnessStore(config); + } + + private static String markerId() { + String runtime = java.lang.management.ManagementFactory.getRuntimeMXBean().getName(); + int at = runtime.indexOf('@'); + return (at > 0 ? runtime.substring(0, at) : "jvm") ; + } + + private static void commitTask(HarnessStore store, String taskId, int payloadBytes) { + final String payload = payloadBytes <= 0 ? "" : repeat('p', payloadBytes); + store.update(new HarnessStateMutation() { + @Override + public HarnessState apply(HarnessState current) { + long now = System.currentTimeMillis(); + current.getTasks().put(taskId, TaskRecord.builder() + .taskId(taskId) + .scopeKey("lab") + .title("lab task") + .goal("reliability lab") + .status(TaskStatus.PLANNED) + .createdBy("HarnessReliabilityLab") + .createdAtEpochMs(now) + .updatedAtEpochMs(now) + .version(1L) + .metadata(Collections.singletonMap("payload", payload)) + .build()); + return current; + } + }); + } + + private static void writer(Path directory, String harnessId, int updates, long pauseMs, long haltAfterMs) throws Exception { + final String id = markerId(); + if (haltAfterMs > 0) { + Thread haltWatchdog = new Thread(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(haltAfterMs); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + // kill -9 analog: no shutdown hooks, no store cleanup. + Runtime.getRuntime().halt(9); + } + }, "halt-watchdog"); + haltWatchdog.setDaemon(true); + haltWatchdog.start(); + } + HarnessStore store = store(directory, harnessId, 0L); + for (int i = 0; i < updates; i++) { + String taskId = "lab-" + id + "-" + i; + commitTask(store, taskId, 0); + System.out.println("COMMITTED " + taskId); + System.out.flush(); + if (pauseMs > 0) { + Thread.sleep(pauseMs); + } + } + System.out.println("DONE " + id); + System.out.flush(); + } + + private static void verify(Path directory, String harnessId, Path markerFile) throws Exception { + TreeSet markers = new TreeSet(); + if (Files.exists(markerFile)) { + for (String line : Files.readAllLines(markerFile, StandardCharsets.UTF_8)) { + if (line.startsWith("COMMITTED ")) { + markers.add(line.substring("COMMITTED ".length()).trim()); + } + } + } + HarnessState state; + try { + state = store(directory, harnessId, 0L).load(); + } catch (RuntimeException error) { + System.out.println("VERIFY LOAD-FAILED " + error.getClass().getSimpleName() + ": " + + String.valueOf(error.getMessage()).substring(0, Math.min(160, String.valueOf(error.getMessage()).length()))); + System.out.flush(); + System.exit(2); + return; + } + TreeSet missing = new TreeSet(markers); + missing.removeAll(state.getTasks().keySet()); + if (missing.isEmpty()) { + System.out.println("VERIFY OK version=" + state.getVersion() + " tasks=" + state.getTasks().size() + + " markers=" + markers.size()); + System.out.flush(); + } else { + System.out.println("VERIFY MISSING version=" + state.getVersion() + " tasks=" + state.getTasks().size() + + " markers=" + markers.size() + " missing=" + missing.size() + " " + missing); + System.out.flush(); + System.exit(3); + } + } + + private static void scale(Path directory, String harnessId, int tasks, int payloadBytes, long compactionBytes) { + HarnessStore store = store(directory, harnessId, compactionBytes); + long[] samples = new long[tasks]; + long buildStart = System.nanoTime(); + String id = markerId(); + for (int i = 0; i < tasks; i++) { + final String payload = repeat('p', payloadBytes); + final String taskId = "scale-" + id + "-" + i; + long start = System.nanoTime(); + store.update(new HarnessStateMutation() { + @Override + public HarnessState apply(HarnessState current) { + long now = System.currentTimeMillis(); + current.getTasks().put(taskId, TaskRecord.builder() + .taskId(taskId) + .scopeKey("scale") + .title("scale task") + .status(TaskStatus.PLANNED) + .createdAtEpochMs(now) + .updatedAtEpochMs(now) + .version(1L) + .metadata(Collections.singletonMap("payload", payload)) + .build()); + return current; + } + }); + samples[i] = System.nanoTime() - start; + } + long buildNanos = System.nanoTime() - buildStart; + System.out.println("SCALE tasks=" + tasks + " payloadBytes=" + payloadBytes + + " compactionBytes=" + compactionBytes + + " buildMs=" + buildNanos / 1_000_000L + + " updateP50Ms=" + percentileMs(samples, 0.50) + + " updateP95Ms=" + percentileMs(samples, 0.95) + + " updateMaxMs=" + percentileMs(samples, 1.0)); + try { + Path stateFile = directory.resolve("state.json"); + Path journalFile = directory.resolve("journal.jsonl"); + System.out.println("SIZE state.json=" + Files.size(stateFile) + + " journal.jsonl=" + (Files.exists(journalFile) ? Files.size(journalFile) : 0L)); + } catch (Exception ignored) { + // size reporting is best-effort + } + for (int attempt = 1; attempt <= 3; attempt++) { + long start = System.nanoTime(); + HarnessState state = store.load(); + long ms = (System.nanoTime() - start) / 1_000_000L; + System.out.println("LOAD attempt=" + attempt + " ms=" + ms + " version=" + state.getVersion() + + " tasks=" + state.getTasks().size()); + } + } + + private static long percentileMs(long[] samples, double quantile) { + long[] sorted = samples.clone(); + java.util.Arrays.sort(sorted); + int index = (int) Math.min(sorted.length - 1L, Math.round(quantile * (sorted.length - 1))); + return sorted[index] / 1_000_000L; + } + + private static String repeat(char ch, int count) { + StringBuilder buffer = new StringBuilder(count); + for (int i = 0; i < count; i++) { + buffer.append(ch); + } + return buffer.toString(); + } +} diff --git a/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessToolExecutorTest.java b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessToolExecutorTest.java new file mode 100644 index 00000000..35b97e2e --- /dev/null +++ b/ai4j-harness/src/test/java/io/github/lnyocly/ai4j/harness/HarnessToolExecutorTest.java @@ -0,0 +1,210 @@ +package io.github.lnyocly.ai4j.harness; + +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecutionStatus; +import io.github.lnyocly.ai4j.agent.tool.AgentToolResult; +import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import io.github.lnyocly.ai4j.agent.permission.AgentPermissionPolicies; +import io.github.lnyocly.ai4j.agent.permission.AgentPermissionToolExecutor; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Comparator; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class HarnessToolExecutorTest { + + private Path directory; + + @Before + public void setUp() throws Exception { + directory = Files.createTempDirectory("ai4j-harness-tool-test-"); + } + + @After + public void tearDown() throws Exception { + if (directory != null && Files.exists(directory)) { + Files.walk(directory) + .sorted(Comparator.reverseOrder()) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (Exception ignored) { + // Best effort cleanup of the test directory. + } + }); + } + } + + @Test + public void startedInvocationAfterReopenRequiresReconciliationWithoutReplay() throws Exception { + HarnessCommandGateway first = gateway("started-recovery"); + ExecutionRecord execution = first.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-started-recovery") + .build()); + AgentToolCall call = call("call-started-recovery", "invocation-started-recovery"); + first.beginToolInvocation(HarnessToolInvocationSpec.builder() + .invocationId("invocation-started-recovery") + .executionId(execution.getExecutionId()) + .toolName(call.getName()) + .callId(call.getCallId()) + .arguments(call.getArguments()) + .build(), HarnessActor.worker("worker-before-crash")); + first.close(); + + HarnessCommandGateway reopened = gateway("started-recovery"); + AtomicInteger calls = new AtomicInteger(); + HarnessToolExecutor executor = new HarnessToolExecutor( + context(reopened, execution), new ToolExecutor() { + @Override + public String execute(AgentToolCall ignored) { + calls.incrementAndGet(); + return "side effect must not be replayed"; + } + }); + + AgentToolExecution result = executor.start(call); + AgentToolResult toolResult = result.await(); + Assert.assertEquals(AgentToolExecutionStatus.UNKNOWN, toolResult.getStatus()); + Assert.assertTrue(toolResult.getOutput().contains("RECONCILIATION_REQUIRED")); + Assert.assertEquals(0, calls.get()); + Assert.assertEquals(ToolInvocationStatus.STARTED, + reopened.getToolInvocation("invocation-started-recovery").getStatus()); + reopened.close(); + } + + @Test + public void concurrentReservationAllowsOnlyOneExternalToolExecution() throws Exception { + HarnessCommandGateway gateway = gateway("concurrent-reservation"); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-concurrent-reservation") + .build()); + AgentToolCall call = call("call-concurrent-reservation", "invocation-concurrent-reservation"); + AtomicInteger calls = new AtomicInteger(); + CountDownLatch ownerStarted = new CountDownLatch(1); + CountDownLatch releaseOwner = new CountDownLatch(1); + ToolExecutor delegate = new ToolExecutor() { + @Override + public String execute(AgentToolCall ignored) throws Exception { + if (calls.incrementAndGet() == 1) { + ownerStarted.countDown(); + Assert.assertTrue(releaseOwner.await(5, TimeUnit.SECONDS)); + } + return "side effect applied once"; + } + }; + HarnessToolExecutor first = new HarnessToolExecutor(context(gateway, execution), delegate); + HarnessToolExecutor second = new HarnessToolExecutor(context(gateway, execution), delegate); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future firstFuture = workers.submit(() -> first.start(call)); + Assert.assertTrue(ownerStarted.await(5, TimeUnit.SECONDS)); + Future secondFuture = workers.submit(() -> second.start(call)); + + AgentToolResult observed = secondFuture.get(5, TimeUnit.SECONDS).await(); + Assert.assertEquals(AgentToolExecutionStatus.UNKNOWN, observed.getStatus()); + releaseOwner.countDown(); + AgentToolResult completed = firstFuture.get(5, TimeUnit.SECONDS).await(); + Assert.assertEquals(AgentToolExecutionStatus.COMPLETED, completed.getStatus()); + Assert.assertEquals(1, calls.get()); + Assert.assertEquals(ToolInvocationStatus.SUCCEEDED, + gateway.getToolInvocation("invocation-concurrent-reservation").getStatus()); + } finally { + releaseOwner.countDown(); + workers.shutdownNow(); + } + gateway.close(); + } + + @Test + public void permissionApprovalAfterReservationRearmsInvocationExactlyOnce() throws Exception { + HarnessCommandGateway gateway = gateway("permission-approval-retry"); + ExecutionRecord execution = gateway.createExecution(HarnessExecutionSpec.builder() + .executionId("execution-permission-approval") + .build()); + AgentToolCall call = call("call-permission-approval", "invocation-permission-approval"); + AtomicInteger calls = new AtomicInteger(); + ToolExecutor delegate = new ToolExecutor() { + @Override + public String execute(AgentToolCall ignored) { + calls.incrementAndGet(); + return "side effect applied after approval"; + } + }; + AgentPermissionToolExecutor permissionExecutor = new AgentPermissionToolExecutor( + delegate, + AgentPermissionPolicies.requireApprovalForTools( + Collections.singleton("apply-side-effect"), "operator approval required")); + HarnessToolExecutor executor = new HarnessToolExecutor( + context(gateway, execution), permissionExecutor); + + AgentToolResult waiting = executor.start(call).await(); + Assert.assertEquals(AgentToolExecutionStatus.WAITING, waiting.getStatus()); + Assert.assertNotNull(waiting.getWaitId()); + Assert.assertEquals(ToolInvocationStatus.WAITING, + gateway.getToolInvocation("invocation-permission-approval").getStatus()); + + gateway.deliverWait(waiting.getWaitId(), Boolean.TRUE); + + AgentToolCall retryCall = callWithoutHarnessInvocation( + "call-permission-approval-retry", "{\"value\":\"one\"}"); + AgentToolResult completed = executor.start(retryCall).await(); + Assert.assertEquals(AgentToolExecutionStatus.COMPLETED, completed.getStatus()); + Assert.assertEquals("side effect applied after approval", completed.getOutput()); + Assert.assertEquals(1, calls.get()); + Assert.assertEquals(ToolInvocationStatus.SUCCEEDED, + gateway.getToolInvocation("invocation-permission-approval").getStatus()); + + AgentToolResult replay = executor.start(call).await(); + Assert.assertEquals(AgentToolExecutionStatus.COMPLETED, replay.getStatus()); + Assert.assertEquals(1, calls.get()); + gateway.close(); + } + + private HarnessCommandGateway gateway(String harnessId) { + return new HarnessCommandGateway( + HarnessPersistence.file(FileHarnessConfig.builder() + .directory(directory.resolve(harnessId)) + .harnessId(harnessId) + .build()).getStore(), + HarnessContract.builder().build(), HarnessActor.agent("test-agent")); + } + + private HarnessExecutionContext context(HarnessCommandGateway gateway, + ExecutionRecord execution) { + return new HarnessExecutionContext(gateway, execution.getExecutionId(), + execution.getTaskId(), execution.getSessionId(), execution.getScopeKey(), + execution.getRunId(), HarnessActor.worker("test-worker"), null); + } + + private AgentToolCall call(String callId, String invocationId) { + return AgentToolCall.builder() + .name("apply-side-effect") + .arguments("{\"value\":\"one\"}") + .callId(callId) + .type("function_call") + .metadata(Collections.singletonMap( + AgentToolCall.METADATA_KEY_HARNESS_INVOCATION_ID, invocationId)) + .build(); + } + + private AgentToolCall callWithoutHarnessInvocation(String callId, String arguments) { + return AgentToolCall.builder() + .name("apply-side-effect") + .arguments(arguments) + .callId(callId) + .type("function_call") + .build(); + } +} diff --git a/ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInTools.java b/ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInTools.java index 8e3cf6a5..6b67e4eb 100644 --- a/ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInTools.java +++ b/ai4j/src/main/java/io/github/lnyocly/ai4j/tool/BuiltInTools.java @@ -14,6 +14,7 @@ public final class BuiltInTools { public static final String BASH = "bash"; + public static final String BASH_PROCESS = "bash_process"; public static final String READ_FILE = "read_file"; public static final String WRITE_FILE = "write_file"; public static final String APPLY_PATCH = "apply_patch"; @@ -23,7 +24,7 @@ public final class BuiltInTools { public static final String UPDATE_AGENTS_MD = "update_agents_md"; private static final Set CODING_TOOL_NAMES = Collections.unmodifiableSet( - new LinkedHashSet(Arrays.asList(BASH, READ_FILE, WRITE_FILE, APPLY_PATCH, GLOB, GREP, EDIT, UPDATE_AGENTS_MD)) + new LinkedHashSet(Arrays.asList(BASH, BASH_PROCESS, READ_FILE, WRITE_FILE, APPLY_PATCH, GLOB, GREP, EDIT, UPDATE_AGENTS_MD)) ); private static final Set READ_ONLY_CODING_TOOL_NAMES = Collections.unmodifiableSet( @@ -32,6 +33,7 @@ public final class BuiltInTools { private static final List CODING_TOOLS = Collections.unmodifiableList(Arrays.asList( bashTool(), + bashProcessTool(), readFileTool(), writeFileTool(), applyPatchTool(), @@ -60,17 +62,29 @@ public static Tool readFileTool() { public static Tool bashTool() { Map properties = new LinkedHashMap(); - properties.put("action", property("string", "bash action to perform.", Arrays.asList("exec", "start", "status", "logs", "write", "stop", "list"))); - properties.put("command", property("string", "Command string to execute. Use exec for self-terminating commands; use start for interactive or long-running commands.")); + properties.put("command", property("string", "Command string to execute. Must be non-interactive and exit by itself; use bash_process for long-running or interactive processes.")); properties.put("cwd", property("string", "Relative working directory inside the workspace.")); - properties.put("timeoutMs", property("integer", "Execution timeout in milliseconds for exec.")); - properties.put("processId", property("string", "Background process identifier.")); - properties.put("offset", property("integer", "Log cursor offset.")); - properties.put("limit", property("integer", "Maximum log characters to return.")); - properties.put("input", property("string", "Text written to stdin for a background process started with action=start.")); + properties.put("timeoutMs", property("integer", "Execution timeout in milliseconds.")); return tool( BASH, - "Execute non-interactive shell commands or manage interactive/background shell processes inside the workspace.", + "Execute a non-interactive shell command that exits by itself.", + properties, + Collections.singletonList("command") + ); + } + + public static Tool bashProcessTool() { + Map properties = new LinkedHashMap(); + properties.put("action", property("string", "Process action to perform.", Arrays.asList("start", "status", "logs", "write", "stop", "list"))); + properties.put("command", property("string", "Command string to start (action=start).")); + properties.put("cwd", property("string", "Relative working directory inside the workspace (action=start).")); + properties.put("processId", property("string", "Background process identifier.")); + properties.put("input", property("string", "Text written to the process stdin (action=write).")); + properties.put("offset", property("integer", "Log cursor offset (action=logs).")); + properties.put("limit", property("integer", "Maximum log characters to return (action=logs).")); + return tool( + BASH_PROCESS, + "Manage interactive or long-running background shell processes: start one that waits for stdin, opens a REPL, starts a server, tails logs, or keeps running; then inspect status, read logs, write stdin, or stop it.", properties, Collections.singletonList("action") ); @@ -81,7 +95,7 @@ public static Tool writeFileTool() { properties.put("path", property("string", "File path to write. Relative paths resolve from the workspace root; absolute paths are allowed.")); properties.put("content", property("string", "Full text content to write.")); properties.put("mode", property("string", "Write mode.", Arrays.asList("create", "overwrite", "append"))); - return tool( + return strictTool( WRITE_FILE, "Create, overwrite, or append a text file.", properties, @@ -134,7 +148,7 @@ public static Tool editTool() { properties.put("old_string", property("string", "Exact text to find in the file. Must match exactly including whitespace and indentation.")); properties.put("new_string", property("string", "Replacement text.")); properties.put("replaceAll", property("boolean", "Replace all occurrences. By default only a single unique match is allowed.")); - return tool( + return strictTool( EDIT, "Perform exact string replacements in a file. The old_string must match uniquely unless replaceAll is true.", properties, @@ -190,6 +204,23 @@ private static Tool tool(String name, return new Tool("function", function); } + /** + * Strict-mode variant for tools whose parameters are all genuinely + * required: the provider then guarantees tool calls conform to the schema. + * (OpenAI strict mode additionally requires every property in + * {@code required} and {@code additionalProperties:false}; + * {@link Tool.Function.Parameter#enforceStrictSchema()} fixes both.) + */ + private static Tool strictTool(String name, + String description, + Map properties, + List required) { + Tool.Function.Parameter parameter = new Tool.Function.Parameter("object", properties, required) + .enforceStrictSchema(); + Tool.Function function = new Tool.Function(name, description, parameter, Boolean.TRUE); + return new Tool("function", function); + } + private static Tool.Function.Property property(String type, String description) { return property(type, description, null); } @@ -211,6 +242,9 @@ private static Tool toolByName(String name) { if (BASH.equals(name)) { return bashTool(); } + if (BASH_PROCESS.equals(name)) { + return bashProcessTool(); + } if (GLOB.equals(name)) { return globTool(); } diff --git a/ai4j/src/test/java/io/github/lnyocly/ai4j/skill/SkillsIChatServiceTest.java b/ai4j/src/test/java/io/github/lnyocly/ai4j/skill/SkillsIChatServiceTest.java index b15b6ad1..536427ad 100644 --- a/ai4j/src/test/java/io/github/lnyocly/ai4j/skill/SkillsIChatServiceTest.java +++ b/ai4j/src/test/java/io/github/lnyocly/ai4j/skill/SkillsIChatServiceTest.java @@ -65,7 +65,7 @@ public void shouldDiscoverSkillsAndAssemblePromptForBasicChatUsage() throws Exce String systemPrompt = Skills.appendAvailableSkillsPrompt("Base prompt.", discovery.getSkills()); Assert.assertTrue(systemPrompt.contains("")); Assert.assertTrue(systemPrompt.contains(skillFile.toAbsolutePath().normalize().toString())); - Assert.assertEquals(8, BuiltInTools.codingTools().size()); + Assert.assertEquals(9, BuiltInTools.codingTools().size()); } @Test diff --git a/ai4j/src/test/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutorTest.java b/ai4j/src/test/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutorTest.java index ead05031..6c23119c 100644 --- a/ai4j/src/test/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutorTest.java +++ b/ai4j/src/test/java/io/github/lnyocly/ai4j/tool/BuiltInToolExecutorTest.java @@ -2,6 +2,7 @@ import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSONObject; +import io.github.lnyocly.ai4j.platform.openai.tool.Tool; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; @@ -128,6 +129,27 @@ public void shouldKeepLegacyBuiltInToolContextConstructor() throws Exception { Assert.assertEquals(200L, context.getDefaultCommandTimeoutMs()); } + @Test + public void bashToolsDeclareSplitExecAndProcessSurfaces() { + // bash is exec-only: no action property, command required. + Tool.Function bash = BuiltInTools.bashTool().getFunction(); + Assert.assertEquals("bash", bash.getName()); + Assert.assertFalse(bash.getParameters().getProperties().containsKey("action")); + Assert.assertEquals(Collections.singletonList("command"), bash.getParameters().getRequired()); + + // bash_process carries the process-management action enum. + Tool.Function process = BuiltInTools.bashProcessTool().getFunction(); + Assert.assertEquals("bash_process", process.getName()); + Assert.assertTrue(process.getParameters().getProperties().containsKey("action")); + Assert.assertEquals(Collections.singletonList("action"), process.getParameters().getRequired()); + Assert.assertTrue(process.getParameters().getProperties().get("action").getEnumValues() + .containsAll(java.util.Arrays.asList("start", "status", "logs", "write", "stop", "list"))); + + // The built-in registry exposes both; bash_process is not read-only. + Assert.assertTrue(BuiltInTools.allCodingToolNames().contains(BuiltInTools.BASH_PROCESS)); + Assert.assertFalse(BuiltInTools.readOnlyCodingToolNames().contains(BuiltInTools.BASH_PROCESS)); + } + private static boolean isWindows() { return System.getProperty("os.name", "").toLowerCase().contains("win"); } diff --git a/benchmarks/.gitignore b/benchmarks/.gitignore new file mode 100644 index 00000000..8fe8ecbb --- /dev/null +++ b/benchmarks/.gitignore @@ -0,0 +1,4 @@ +benchmarks/*.json.process +closeout-packet.json +submission-packet.json +review-*.json diff --git a/benchmarks/harnessbench-ai4j/README.md b/benchmarks/harnessbench-ai4j/README.md new file mode 100644 index 00000000..1640aeb6 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/README.md @@ -0,0 +1,219 @@ +# HarnessBench adapter for ai4j + +Run [HarnessBench](https://github.com/codex-harness/harnessbench) (a real-filesystem +agent/harness benchmark) against the ai4j SDK in two modes: + +| Mode | What runs | What is durable | +|------|-----------|-----------------| +| `harness` (default) | `CodingAgentHarness` keyed by the bench session id | tasks, executions, checkpoints, waits/wakeups, gates, reviews, idempotency — file-backed store under the sandbox | +| `bare` | plain `Agent` per round | JSONL transcript replay only (the honest control: no durable tasks, gates, or audit state) | + +The adapter is **generic_cli-only**: HarnessBench's stock `generic_cli` adapter +invokes `bin/ai4j-bridge.sh` once per round with `HARNESSBENCH_*` environment +variables. No Python adapter class is added to the benchmark or to the SDK, and +no benchmark dependency enters production Maven modules. + +## Prerequisites + +- JDK 8+ (Java 8 bytecode baseline for the bridge), Maven 3.6+ +- Python 3 (stdlib only) for `audit/check_audit.py` +- Bash (on Windows: Git Bash) — the runner invokes `bash bin/ai4j-bridge.sh` +- A HarnessBench checkout placed outside this repository (e.g. `.tmp/harness-bench`, gitignored) + +## One-time setup + +```bash +# 1. install the SDK modules the bridge links against (skips the local +# SpotBugs doc gate; CI still enforces it) +mvn -pl ai4j,ai4j-agent,ai4j-harness,ai4j-coding -am -DskipTests -Dspotbugs.skip=true install + +# 2. build the bridge classpath + classes +benchmarks/harnessbench-ai4j/bin/ai4j-bridge.sh build +``` + +## Configuration + +Copy `config/harnessbench-ai4j.example.yaml` into the HarnessBench checkout as +`config/harness.yaml` (or merge the `models:` entries into an existing file), +then export the provider configuration in the shell that runs HarnessBench: + +| Env var | Meaning | +|---------|---------| +| `AI4J_BENCH_MODE` | `harness` (default) or `bare` | +| `AI4J_BENCH_API_KEY` | provider API key (**required for live runs; keep out of git**) | +| `AI4J_BENCH_BASE_URL` | optional OpenAI-compatible base URL | +| `AI4J_BENCH_MODEL` | model id passed to the agent (default `gpt-4o-mini`) | +| `AI4J_BENCH_MAX_STEPS` | per-round step budget (default 24) | +| `AI4J_BENCH_AUTO_RESUME` | continue continuation slices inside one round (default `true`) | +| `AI4J_BENCH_STATE_DIR` | durable state dir (default `/ai4j-state`) | +| `AI4J_BENCH_AUDIT_DIR` | audit artifact dir (default `/ai4j-audit`) | +| `AI4J_BENCH_SCRIPT` | **protocol/smoke only**: scripted-model scenario JSON, no network | + +The bridge reads `HARNESSBENCH_TASK_ID/WORKSPACE/SANDBOX/SESSION_ID/PROMPT_FILE/MODEL_ID` +from the generic_cli environment. The benchmark `task_id` is **never** turned +into a pre-registered business Task; harness-mode agents create their own tasks +at runtime through the harness management tool surface. + +## Running + +```bash +cd .tmp/harness-bench +python -m harnessbench.cli run-task --task 057-interruption-resume --harness ai4j-harness +python -m harnessbench.cli run-task --task 057-interruption-resume --harness ai4j-bare # control +python -m harnessbench.cli run-suite --harness ai4j-harness --from-num 57 --to-num 59 +``` + +Protocol smoke without credentials or Docker: + +```bash +benchmarks/harnessbench-ai4j/tests/run_protocol_tests.sh +``` + +## Audit artifacts + +Each round writes two machine-readable files under the sandbox: + +- `ai4j-audit/harness_audit.json` — sanitized projection read back from the + durable store on disk (tasks, executions, checkpoints, waits, wakeups, + gates, submissions, reviews, tool invocations, idempotency key count). + Prompts, transcripts, and secrets are never exported. +- `ai4j-audit/execution_trace.json` — per-round bridge timeline (status, + execution/task/wait ids, bounded output preview, error). + +`audit/check_audit.py ` validates invariants over these exports: +runtime (dynamic) task creation, execution→task references, round continuity +across fresh JVMs, checkpoint↔execution consistency, wait/wakeup pairing, +gate-before-complete, tool-invocation uniqueness, UNKNOWN-preserved-for-reconciliation. +Capability-dependent checks report `not exercised` instead of passing vacuously. + +Exit codes: `0` round finished (COMPLETED / CONTINUATION_REQUIRED / WAITING / +BLOCKED / IN_REVIEW), `1` failed, `2` UNKNOWN/CANCELLED (operators reconcile; +the bridge never blindly retries). + +## What official runs prove vs. what they cannot + +See [docs/COVERAGE.md](docs/COVERAGE.md) for the category matrix: which of the +eight HarnessBench classes are covered by the official oracle, which only yield +LLM-rubric quality signals, and which Harness-internal guarantees (durable +state, cross-process recovery, idempotent redelivery, approval gates, +UNKNOWN/cancel handling) require the audit exports and the ai4j-harness module +test suite. + +## 实测对照(2026-09-01,gpt-5.6-terra / reasoning medium / 同网关同 key) + +优先任务 × 五臂(各臂裸配置:codex `--yolo` 无 skill、opencode `--pure`、pi `--no-skills --no-extensions`、hermes oneshot): + +| 臂 | 001 | 057 | 058 | 059 | 105 | 106 | 多轮均值 | 轮次稳定率 | +|---|---|---|---|---|---|---|---|---| +| **ai4j-harness** | 1.00 | 0.81 | **1.00** | **1.00** | **0.72** | **0.64** | **0.834** | 100% | +| codex CLI | 1.00 | 0.88 | 0.30* | 0.24* | 0.22* | 0.47 | 0.421 | 50% | +| hermes | 1.00 | 0.73 | **1.00** | **1.00** | 0.22 | 0.51 | 0.692 | 100% | +| opencode | 1.00 | 0.81 | **1.00** | **1.00** | 0.56 | 0.47 | 0.767 | 100% | +| pi | 1.00 | 1.00 | 0.00* | **1.00** | 0.70 | 0.61 | 0.662 | 83% | + +\* 该轮 agent 进程非零退出(模型自述未完成),分数仍计入。各臂 n=1/格,注意方差。 + +**ai4j 臂多采样复核(同配置 3 样本取中位,strict 分档 + 精简提示词后)**: +057=0.73([0.69,0.73,0.89])、058=0.92([0.86,0.92,1.00])、059=1.00(三连满分)、 +105=0.78([0.76,0.79])、106=0.74([0.63,0.85])→ **中位均值 0.833**。 +在该配置和样本下,ai4j 臂多轮均值高于对照臂点估值(最高 0.767);这不是总体能力排名, +059 跨 4+ 次运行全满分,轮次完成率 100%(codex 50%)。单样本 0.867 属高方差侧; +106 方差最大(0.63–0.85),057 的失分项(state_scores/skip_audit)为模型输出 +形状的采样方差(oracle 要求 dict+status 字段),非框架缺陷。 + +## Long-running 类扩展覆盖(2026-09-02,ai4j-harness 臂,同模型同配置) + +此前未实测的 6 个 Long-running 类任务各跑 1 次 live: + +| 任务 | combined | 失分项 | 归因 | +|---|---|---|---| +| 007-session-memory | 1.00 | — | | +| 014-task-decomposition | 0.89 | progress_tracking | progress.md 只写 start→done,无 pending 生命周期标记(oracle 查 4 态词汇);输出习惯差异 | +| 060-task-cancellation-cleanup | 1.00 | — | | +| 061-periodic-status-rollup | 1.00 | — | | +| 103-policy-update-replan-diff | 0.60 | original_plan(0.5)、revised_plan(0.0) | oracle 只认顶层 `decisions/plan_items/items` 数组且要求 item 级 workstream 字段,prompt 未规定 schema;模型按 workstreams 分组嵌套 decisions(提示词的合理读法)→ item 级检查全灭。模型输出形状与 oracle 隐性契约的采样交互,非 SDK 缺陷 | +| 104-async-ops-window-rollup | 0.80 | state(0.55) | 模型把被忽略的 UP-LATE/UP-OLD 也记入 `seen_update_ids`;oracle 要求与合法集严格相等。语义分歧("观察到" vs "计为有效") | + +6 任务均值 **0.881**(本类最高批次);adapter 6/6 成功,轮次完成率 100%(含 339s/488s/588s +长轮,均在 900s 墙钟预算内,无截断)。 + +**103 形状敏感性因果与分布(本地诊断 + 扩展采样)**:仅给 prompt_round1/2 补上 +"top-level `decisions` 扁平数组 + 每个 decision 自带 workstream/region" 的显式 schema +(oracle 未动),同模型同臂复跑 **0.60 → 0.98**——证实 0.60 的失分是输出形状与 +oracle 隐性契约的交互,非能力缺陷。基线 prompt 下扩展采样 n=6(2026-09-03): +**5 扁平 / 1 嵌套(扁平率 83%,与 pi/hermes 一致)**,分数 [0.60, 0.88, 0.92, 0.98, +1.0, 1.0],**中位 0.98**——官方记录的 0.60 是分布左尾的单次抽样。是否属出题方 +有意设计无从外部判断,本仓库将其记为已知评测特性并如实计分,不改动基准、 +不据此提分,本地补丁仅用于诊断且已还原;补丁后分数不与官方矩阵可比。 + +## Long-running 类五臂对照(2026-09-02,全部基线 prompt,无任何臂注入 schema 提示) + +同 6 任务、同模型(gpt-5.6-terra / medium)、各臂裸配置,单样本/格: + +| 臂 | 007 | 014 | 060 | 061 | 103 | 104 | 均值 | +|---|---|---|---|---|---|---|---| +| ai4j-harness | 1.00 | 0.89 | 1.00 | 1.00 | 0.60 | 0.80 | 0.881 | +| codex CLI | 0.25 | 0.86 | 1.00 | 1.00 | 0.96 | 0.87 | 0.824 | +| hermes | 1.00 | 0.91 | 1.00 | 1.00 | 0.98 | 0.10 | 0.831 | +| opencode | 1.00 | 0.91 | 1.00 | 0.82 | 0.88 | 0.80 | 0.900 | +| pi | 1.00 | 0.91 | 1.00 | 1.00 | 1.00 | 0.89 | **0.966** | + +单样本下 pi 领先。**失分归因与方差复核**(关键): + +- ai4j 与 pi 的差距 100% 集中在 103(+0.40)与 104(+0.09)——两题 oracle 均假设了 + prompt 未声明的输出形状(103 顶层扁平 decisions;104 `latest_by_component` 期望 + 状态字符串)。其余 4 题各臂实质并列(1.00×4,014 差 0.02 为词汇级波动)。 +- **同臂复跑证明形状选择是采样掷硬币,非框架偏向**:ai4j 臂同 prompt 复跑 103 得 + [0.60 嵌套, 超时失败, **1.00 扁平**];pi 复跑得 [1.00, 0.70(时区混写触发 oracle + 异常), 失败(文件写错目录)]。两臂 103 分布完全重叠,单样本排名在此类任务上是噪声。 + 扩展采样(2026-09-03,再增 4 个基线样本):ai4j 臂基线 103 有效样本 n=6 中 + **5 扁平 / 1 嵌套(扁平率 83%)**,重放分数 [1.0, 1.0, 0.88, 0.92, 0.98]—— + 与对照臂扁平率一致,排除本方提示词/工具面导致嵌套偏向的假设;系统提示词逐句 + 审计亦无任何形状/分组词汇。 +- 真实框架级信号(durability 主场):codex 007=0.25(跨轮会话丢失 memory_secret, + 其余四臂全 1.00);hermes 104=0.10(out/ 全空崩溃)。ai4j 本批 0 硬失败。 +- 环境注记:codex 臂在 Windows 需 `PYTHONUTF8=1`(adapter 在中文 locale 下以系统 + GBK 默认编码读 session JSONL,遇非 ASCII 崩溃;`PYTHONUTF8=1` 后 6/6 通过)。 + +Long-running 类 11 任务累计(含 057–059/105/106 +中位数):均值约 0.860。本批 6 任务未发现 SDK 缺陷,失分全部为模型输出形状或 +oracle 语义严格性差异。 + +## 跨类通用性批次(2026-09-03,ai4j-harness 臂,每类 2 个代表) + +Software/Workspace/Knowledge/Data 四类各 2 代表、基线 prompt、单样本(Workspace 的 +078 需要 cloudflared 公网隧道环境前置,环境阻塞,以 020 替补): + +| 类 | 任务 | combined | 失分项 | +|---|---|---|---| +| Software | 016-code-repair-pytest | 0.70 | `test_file_hash_unchanged`——**oracle 金标 hash 与自带 fixture 不一致**(基准打包问题,见下)| +| Software | 009-git-pr-merge | 1.00 | — | +| Workspace | 002-exec | 1.00 | — | +| Workspace | 020-archive-checksum | 1.00 | — | +| Knowledge | 015-security-injection-defense | 0.70 | 隔离区文件命名/多余文件(模型内容级)| +| Knowledge | 036-citation-consistency-audit | 0.78 | errors_csv 一处(模型内容级)| +| Data | 051-sql-query-report | 0.69 | `fixtures_unchanged`——**同上 stale golden hash**;区域/日期两项为本轮模型内容错误 | +| Data | 049-excel-like-cleaning | 1.00 | — | + +**016/051 失分项修正(2026-09-04 复核)**:这两个"勿改输入"检查是基准侧 +stale golden hash——被检文件在**所有**沙箱(含未加保护的首轮批次)与任务自带 +fixture 的 md5/sha256 **完全一致**(016:oracle 硬编码 `52e242ab…` vs fixture 实际 +`bac01bef…`;051:ground_truth `57bc9430…` vs fixture 实际 `f11cc48f…`), +文件从未被改动。该族问题与 103 schema、codex GBK 同属基准自身不一致,官方分数 +如实保留(016 的 hash 检查恒不可过、权重 0.30)。 + +**输入保护加固(同日)**:基于早期误读曾以为模型乱改输入,为此给 +`WorkspaceContext.excludedPaths` 增加 glob 模式(`**/test_*.py` 等,段名匹配向后 +兼容)、guard 拒写信息附策略引导、bridge 新增 `AI4J_BENCH_PROTECTED_PATHS`。 +单测 16/16、live 复测(051 配 `in`、016 配 `**/test_*.py`)验证文件保持 pristine +(md5=模板)。该能力保留为通用加固(防御真实用户的输入区被写),但**它不改变 +016/051 的官方分数**——失分根因在基准侧。 + +## 覆盖范围(对全部 106 任务) + +HarnessBench 共 8 类 106 任务。当前官方实测 **20 个次**(去重 20 个任务;同一任务的多臂/复跑不重复计入覆盖任务数): +Long-running 类 **11/11 全覆盖**(含五臂对照与方差复核)、Software/Workspace/ +Knowledge/Data 各 2 代表、另有 001。**Office 12、Vertical 12、SRE 7 尚未实测**—— +本 README 的跨臂对照结论仅在 Long-running 类内成立;跨类单臂不变量结论覆盖上述 +5 域。Office/Vertical 以长文档综合为主(考模型多于考框架)、SRE 为运维推理, +需要时每类 1–2 代表即可补齐。 diff --git a/benchmarks/harnessbench-ai4j/audit/check_audit.py b/benchmarks/harnessbench-ai4j/audit/check_audit.py new file mode 100644 index 00000000..e486eed2 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/audit/check_audit.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Invariant checks over ai4j bridge audit exports. + +Usage: + python check_audit.py [--expect-status COMPLETED] + +The checker validates machine-readable invariants of ``harness_audit.json`` +(state projection read back from the durable harness store) and +``execution_trace.json`` (per-round bridge timeline). Checks that depend on a +specific capability being exercised print ``not exercised`` and pass; checks +whose precondition IS present must hold, otherwise the script exits non-zero. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +class Report: + def __init__(self) -> None: + self.passed: list[str] = [] + self.failed: list[str] = [] + self.skipped: list[str] = [] + + def ok(self, cid: str, detail: str = "") -> None: + self.passed.append(f"{cid}: {detail}" if detail else cid) + + def fail(self, cid: str, detail: str = "") -> None: + self.failed.append(f"{cid}: {detail}" if detail else cid) + + def skip(self, cid: str, detail: str = "") -> None: + self.skipped.append(f"{cid} [not exercised]: {detail}" if detail else f"{cid} [not exercised]") + + def exit_code(self) -> int: + return 1 if self.failed else 0 + + +def load_audit(target: Path) -> tuple[dict, Path]: + if target.is_dir(): + target = target / "ai4j-audit" / "harness_audit.json" + if not target.is_file(): + raise SystemExit(f"audit file not found: {target}") + return json.loads(target.read_text(encoding="utf-8")), target + + +def check(audit: dict, expect_status: str | None, report: Report) -> None: + state = audit.get("state") or {} + + if audit.get("schema") != "ai4j-harness-audit/v1": + report.fail("schema", f"unexpected schema {audit.get('schema')!r}") + else: + report.ok("schema") + + if expect_status: + status = audit.get("status") + if status == expect_status: + report.ok("expected-status", f"status == {expect_status}") + else: + report.fail("expected-status", f"status={status} expected={expect_status}") + + if audit.get("mode") != "harness": + report.skip("durable-state", "bare mode has no harness state projection") + return + + tasks = state.get("tasks") or [] + executions = state.get("executions") or [] + checkpoints = state.get("checkpoints") or [] + waits = state.get("waits") or [] + wakeups = state.get("wakeups") or [] + gates = state.get("gates") or [] + tool_invocations = state.get("toolInvocations") or [] + + # Dynamic task creation: the bridge never pre-creates a business Task from + # the benchmark task id, so a harness-created task must not reuse it. + if tasks: + bench_id = audit.get("benchTaskId") + dynamic = all(t.get("taskId") != bench_id for t in tasks) + if dynamic: + report.ok("dynamic-task", f"task ids {[t.get('taskId') for t in tasks]} are runtime-created") + else: + report.fail("dynamic-task", "a task reuses the benchmark task id (pre-registered)") + + # A task referenced by executions must exist in the store. Executions + # without a task binding are session-scoped runs (allowed before any + # task is discovered). + task_ids = {t.get("taskId") for t in tasks} + dangling = [e.get("executionId") for e in executions + if e.get("taskId") is not None and e.get("taskId") not in task_ids] + if dangling: + report.fail("execution-task-ref", f"executions reference unknown tasks: {dangling}") + elif executions: + report.ok("execution-task-ref", "all bound executions reference stored tasks") + else: + report.skip("execution-task-ref", "no executions recorded") + else: + report.skip("dynamic-task", "no task recorded") + + # Cross-process continuation: each benchmark round is a fresh JVM, so any + # state linking rounds (same task, same session) proves durable store + # continuity rather than shared process memory. + by_task: dict[str, list[dict]] = {} + for e in executions: + by_task.setdefault(e.get("taskId"), []).append(e) + multi = {tid: es for tid, es in by_task.items() if len(es) > 1} + if multi: + for tid, es in multi.items(): + sessions = {e.get("sessionId") for e in es} + if len(sessions) > 1: + report.fail("round-continuity", f"task {tid} split across sessions {sessions}") + if not any(report.failed): + report.ok("round-continuity", f"task(s) {sorted(multi)} continued across fresh processes") + # Executions that reference a checkpoint must find it in the store. + cp_ids = {c.get("checkpointId") for c in checkpoints} + missing = [e.get("executionId") for e in executions + if e.get("checkpointId") and e.get("checkpointId") not in cp_ids] + if missing: + report.fail("checkpoint-record-exists", f"missing checkpoint records for {missing}") + elif any(e.get("checkpointId") for e in executions): + report.ok("checkpoint-record-exists", f"{len(checkpoints)} checkpoint(s) consistent") + else: + report.skip("checkpoint-record-exists", "no execution references a checkpoint") + elif executions: + report.skip("round-continuity", "single execution for this session so far") + report.skip("checkpoint-record-exists", "no multi-round continuation yet") + + # Wait/wakeup pairing: a delivered wait must have been woken exactly once. + open_waits = [w for w in waits if str(w.get("status")).upper() in ("OPEN", "PENDING")] + delivered = [w for w in waits if str(w.get("status")).upper() == "DELIVERED"] + if delivered or wakeups: + for w in delivered: + paired = [wk for wk in wakeups if wk.get("waitId") == w.get("waitId")] + if len(paired) != 1: + report.fail("wait-wakeup-pairing", f"wait {w.get('waitId')} has {len(paired)} wakeups") + else: + report.ok("wait-wakeup-pairing", f"wait {w.get('waitId')} woken once") + else: + report.skip("wait-wakeup-pairing", "no delivered waits") + + if open_waits: + report.ok("open-wait-persisted", f"{len(open_waits)} open wait(s) survive process exit") + else: + report.skip("open-wait-persisted", "no open waits") + + # Approval gating: a pending gate must not coexist with a completed task. + pending_gates = [g for g in gates if str(g.get("status")).upper() in ("PENDING", "WAITING", "OPEN")] + if pending_gates: + completed = [t for t in tasks if str(t.get("status")).upper() == "COMPLETED" + and any(g.get("taskId") == t.get("taskId") for g in pending_gates)] + if completed: + report.fail("gate-before-complete", f"task completed with pending gate: {[g.get('gateId') for g in pending_gates]}") + else: + report.ok("gate-before-complete", f"{len(pending_gates)} pending gate(s) block completion") + else: + report.skip("gate-before-complete", "no gates recorded") + + # Async tool invocations must leave an idempotency-proof trail: an + # operation with a waitId must appear at most once per callId. + if tool_invocations: + seen: dict[tuple, int] = {} + dupes = [] + for ti in tool_invocations: + key = (ti.get("callId"), ti.get("toolName")) + seen[key] = seen.get(key, 0) + 1 + dupes = [k for k, n in seen.items() if n > 1] + if dupes: + report.fail("tool-invocation-uniqueness", f"duplicate invocations: {dupes}") + else: + report.ok("tool-invocation-uniqueness", f"{len(tool_invocations)} unique invocation(s)") + else: + report.skip("tool-invocation-uniqueness", "no tool invocations") + + # UNKNOWN handling: preserve the ambiguous execution and reject any later + # execution for the same task/session. A mere UNKNOWN record is not enough. + if str(audit.get("status")).upper() == "UNKNOWN": + unknown_execs = [e for e in executions if str(e.get("status")).upper() == "UNKNOWN"] + if not unknown_execs: + report.fail("unknown-preserved", "bridge reported UNKNOWN but store has no UNKNOWN execution") + else: + retry = [] + for unknown in unknown_execs: + unknown_time = max(int(unknown.get("updatedAtEpochMs") or 0), int(unknown.get("finishedAtEpochMs") or 0), int(unknown.get("createdAtEpochMs") or 0)) + for candidate in executions: + if candidate is unknown or candidate.get("taskId") != unknown.get("taskId") or candidate.get("sessionId") != unknown.get("sessionId"): + continue + candidate_time = max(int(candidate.get("createdAtEpochMs") or 0), int(candidate.get("startedAtEpochMs") or 0), int(candidate.get("updatedAtEpochMs") or 0)) + if candidate_time > unknown_time or candidate.get("attempt", 0) > unknown.get("attempt", 0): + retry.append(candidate.get("executionId")) + if retry: + report.fail("unknown-preserved", f"UNKNOWN execution followed by retry: {retry}") + else: + report.ok("unknown-preserved", "UNKNOWN execution kept for reconciliation without retry") + else: + report.skip("unknown-preserved", "status is not UNKNOWN") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("target", type=Path, help="sandbox dir or harness_audit.json path") + parser.add_argument("--expect-status", default=None, help="audit status the run must report") + args = parser.parse_args() + + audit, path = load_audit(args.target) + report = Report() + check(audit, args.expect_status, report) + + print(f"audit: {path}") + for line in report.passed: + print(f" PASS {line}") + for line in report.skipped: + print(f" SKIP {line}") + for line in report.failed: + print(f" FAIL {line}") + print(f"{len(report.passed)} passed, {len(report.skipped)} not exercised, {len(report.failed)} failed") + return report.exit_code() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/benchmarks/harnessbench-ai4j/audit/test_check_audit.py b/benchmarks/harnessbench-ai4j/audit/test_check_audit.py new file mode 100644 index 00000000..1772781a --- /dev/null +++ b/benchmarks/harnessbench-ai4j/audit/test_check_audit.py @@ -0,0 +1,23 @@ +import unittest +from check_audit import Report, check + +def ex(i, status, t, attempt=1): + return {'executionId': i, 'taskId': 'task-1', 'sessionId': 'session-1', 'status': status, 'attempt': attempt, 'createdAtEpochMs': t, 'updatedAtEpochMs': t, 'finishedAtEpochMs': t} + +class UnknownAuditTest(unittest.TestCase): + def run_check(self, executions): + report = Report() + check({'schema': 'ai4j-harness-audit/v1', 'mode': 'harness', 'status': 'UNKNOWN', 'benchTaskId': 'bench-1', 'state': {'tasks': [{'taskId': 'task-1', 'status': 'ACTIVE'}], 'executions': executions}}, None, report) + return report + + def test_unknown_without_retry_passes(self): + report = self.run_check([ex('exec-1', 'UNKNOWN', 100)]) + self.assertTrue(any(item.startswith('unknown-preserved:') for item in report.passed)) + self.assertFalse(report.failed) + + def test_unknown_followed_by_retry_fails(self): + report = self.run_check([ex('exec-1', 'UNKNOWN', 100), ex('exec-2', 'COMPLETED', 200, 2)]) + self.assertTrue(any(item.startswith('unknown-preserved:') for item in report.failed)) + +if __name__ == '__main__': + unittest.main() diff --git a/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-bare.sh b/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-bare.sh new file mode 100644 index 00000000..8b528156 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-bare.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Bare-agent wrapper: same as ai4j-bridge.sh with AI4J_BENCH_MODE=bare. +export AI4J_BENCH_MODE="bare" +exec "$(dirname "${BASH_SOURCE[0]}")/ai4j-bridge.sh" run diff --git a/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-harness.sh b/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-harness.sh new file mode 100644 index 00000000..e3fedeee --- /dev/null +++ b/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-harness.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# Harness-mode wrapper: same as ai4j-bridge.sh with AI4J_BENCH_MODE=harness. +export AI4J_BENCH_MODE="${AI4J_BENCH_MODE:-harness}" +exec "$(dirname "${BASH_SOURCE[0]}")/ai4j-bridge.sh" run diff --git a/benchmarks/harnessbench-ai4j/bin/ai4j-bridge.sh b/benchmarks/harnessbench-ai4j/bin/ai4j-bridge.sh new file mode 100644 index 00000000..cf9ad838 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/bin/ai4j-bridge.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# HarnessBench generic_cli entry for the ai4j bridge. +# +# One-time setup (builds the dependency classpath and compiles the bridge): +# benchmarks/harnessbench-ai4j/bin/ai4j-bridge.sh build +# +# Benchmark rounds are invoked by the HarnessBench generic_cli adapter with +# HARNESSBENCH_* / AI4J_BENCH_* environment variables; no script args needed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BENCH_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$BENCH_ROOT/../.." && pwd)" +TARGET="$BENCH_ROOT/target" +CP_FILE="$TARGET/classpath.txt" +CLASSES="$TARGET/classes" +SRC="$BENCH_ROOT/java/io/github/lnyocly/ai4j/harnessbench/HarnessBenchBridge.java" +MAIN_CLASS="io.github.lnyocly.ai4j.harnessbench.HarnessBenchBridge" + +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) CP_SEP=";" ;; + *) CP_SEP=":" ;; +esac +# javac.exe needs Windows-style paths for the classpath under Git Bash/MSYS. +REPO_ROOT_WIN="$(cygpath -w "$REPO_ROOT" 2>/dev/null || echo "$REPO_ROOT")" +CLASSES_WIN="$(cygpath -w "$CLASSES" 2>/dev/null || echo "$CLASSES")" + +build_classpath() { + if [ ! -f "$CP_FILE" ]; then + mkdir -p "$TARGET" + (cd "$REPO_ROOT" && mvn -q -pl ai4j-coding -am dependency:build-classpath \ + -Dmdep.outputFile="$CP_FILE") + fi +} + +compile_bridge() { + mkdir -p "$CLASSES" + local stale=0 + if [ ! -f "$CLASSES/$MAIN_CLASS.class" ]; then + stale=1 + elif [ -n "$(find "$BENCH_ROOT/java" -name '*.java' -newer "$CLASSES/$MAIN_CLASS.class" 2>/dev/null)" ]; then + stale=1 + fi + if [ "$stale" = "1" ]; then + local full_cp="$REPO_ROOT_WIN/ai4j-coding/target/classes$CP_SEP$(cat "$CP_FILE")" + # Java 8 baseline: prefer --release 8 (JDK 9+), fall back on JDK 8. + if ! javac --release 8 -encoding UTF-8 -cp "$full_cp" -d "$CLASSES" "$SRC" 2>/dev/null; then + javac -source 8 -target 8 -encoding UTF-8 -cp "$full_cp" -d "$CLASSES" "$SRC" + fi + fi +} + +case "${1:-run}" in + build) + build_classpath + compile_bridge + echo "bridge ready: $CLASSES" + ;; + run) + build_classpath + compile_bridge + # build-classpath lists dependencies only; add the coding module itself. + local_cp="$CLASSES_WIN$CP_SEP$REPO_ROOT_WIN/ai4j-coding/target/classes$CP_SEP$(cat "$CP_FILE")" + exec java -cp "$local_cp" "$MAIN_CLASS" + ;; + *) + echo "usage: $0 [build|run]" >&2 + exit 64 + ;; +esac diff --git a/benchmarks/harnessbench-ai4j/bin/harnessbench-round.sh b/benchmarks/harnessbench-ai4j/bin/harnessbench-round.sh new file mode 100644 index 00000000..4f84fcfb --- /dev/null +++ b/benchmarks/harnessbench-ai4j/bin/harnessbench-round.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# HarnessBench generic_cli round runner for stock agent CLIs (comparison arms). +# +# generic_cli invokes: harnessbench-round.sh +# with cwd = benchmark workspace. Round continuity: +# pi -> --session-id (same session across rounds) +# opencode -> -c (continue last session for this project dir) +# hermes -> --continue (last session in the sandbox-local HERMES_HOME) +# Exit code becomes the round result (0 = round completed). +set -euo pipefail + +TOOL="${1:?tool required}" +PROMPT_FILE="${2:?prompt_file required}" +SESSION_ID="${3:?session_id required}" + +MODEL="${AGENT_ROUND_MODEL:-gpt-5.6-terra}" +THINKING="${AGENT_ROUND_THINKING:-medium}" +SANDBOX_DIR="$(cd "$(dirname "$PROMPT_FILE")" && pwd)" +HERMES_BIN="${HERMES_BIN:-C:/Users/1/AppData/Local/hermes/bin/hermes.exe}" + +case "$TOOL" in + opencode) + PROMPT="$(cat "$PROMPT_FILE")" + # --pure: no external plugins — raw agent, same as the other arms. + if [ "${AGENT_ROUND_CONTINUE:-1}" = "1" ]; then + if ! opencode run --pure -m "trovebox/$MODEL" -c "$PROMPT" 2>/dev/null; then + # First round in this workspace: nothing to continue yet. + opencode run --pure -m "trovebox/$MODEL" "$PROMPT" + fi + else + opencode run --pure -m "trovebox/$MODEL" "$PROMPT" + fi + ;; + pi) + PROMPT="$(cat "$PROMPT_FILE")" + exec pi -p --thinking "$THINKING" --provider trovebox --model "$MODEL" \ + --session-id "$SESSION_ID" --no-skills --no-extensions "$PROMPT" + ;; + hermes) + export HERMES_HOME="$SANDBOX_DIR/ai4j-state/hermes-home" + mkdir -p "$HERMES_HOME" + # hermes reads $HERMES_HOME/config.yaml; a fresh home without one + # falls back to its default provider flow. + cp "G:/My_Project/java/ai4j-sdk/.tmp/harness-bench/config/hermes-luna/config.yaml" \ + "$HERMES_HOME/config.yaml" + if [ "${AGENT_ROUND_CONTINUE:-1}" = "1" ]; then + if ! "$HERMES_BIN" chat --oneshot --query-file "$PROMPT_FILE" \ + --reasoning "$THINKING" --continue 2>/dev/null; then + # First round: no session to continue yet. + "$HERMES_BIN" chat --oneshot --query-file "$PROMPT_FILE" \ + --reasoning "$THINKING" + fi + else + "$HERMES_BIN" chat --oneshot --query-file "$PROMPT_FILE" \ + --reasoning "$THINKING" + fi + ;; + *) + echo "unknown tool: $TOOL (opencode|pi|hermes)" >&2 + exit 64 + ;; +esac diff --git a/benchmarks/harnessbench-ai4j/config/harnessbench-ai4j.example.yaml b/benchmarks/harnessbench-ai4j/config/harnessbench-ai4j.example.yaml new file mode 100644 index 00000000..d8f0ea96 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/config/harnessbench-ai4j.example.yaml @@ -0,0 +1,34 @@ +# HarnessBench model-config entries for the ai4j bridge. +# +# Copy the `models:` entries below into the HarnessBench checkout's +# config/harness.yaml (or point HARNESSBENCH_HARNESS_CONFIG at this file), +# replacing with this repository's absolute path. +# The adapter is the stock `generic_cli`; the bridge shell script receives +# all context through HARNESSBENCH_* environment variables. +# +# Provider configuration travels through the environment of the shell that +# runs HarnessBench (generic_cli copies os.environ into the adapter env); +# the model_config `env` key is NOT forwarded by generic_cli: +# AI4J_BENCH_API_KEY provider API key (required for live runs; never commit) +# AI4J_BENCH_BASE_URL optional OpenAI-compatible base URL +# AI4J_BENCH_MODEL model id for the agent (default gpt-4o-mini) +# +# Protocol smoke without credentials: +# export AI4J_BENCH_SCRIPT=/path/to/scenario.json (see tests/scenarios/) + +models: + ai4j-harness: + adapter: generic_cli + command: bash + args: + - "/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-harness.sh" + session_prefix: harnessbench-ai4j + timeout_sec: 900 + + ai4j-bare: + adapter: generic_cli + command: bash + args: + - "/benchmarks/harnessbench-ai4j/bin/ai4j-bridge-bare.sh" + session_prefix: harnessbench-ai4j-bare + timeout_sec: 900 diff --git a/benchmarks/harnessbench-ai4j/docs/COVERAGE.md b/benchmarks/harnessbench-ai4j/docs/COVERAGE.md new file mode 100644 index 00000000..3c6b70b6 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/docs/COVERAGE.md @@ -0,0 +1,76 @@ +# HarnessBench coverage matrix for the ai4j bridge + +HarnessBench 2.0 checkout: 106 tasks across 8 classes. This matrix separates +**what each evidence channel can actually prove** for the ai4j integration. + +Evidence channels: + +- **Official oracle** — deterministic `oracle_grade.py` over the workspace + after the run. Proves end-task outcomes, not harness internals. +- **LLM rubric** — `llm_rubric.py` quality signal only. Never evidence of + harness correctness. +- **Protocol smoke** — `tests/run_protocol_tests.sh`: scripted-model, + credential-free runs of the real bridge + real file-backed harness store. + Proves the adapter contract, env/exit-code contract, and durable-state + readback, cross-process. +- **Custom audit** — `audit/check_audit.py` over `harness_audit.json` + (state projection read back from disk). Proves harness-internal invariants + for a given run. +- **Module suite** — `ai4j-harness` unit tests (50 tests) own the deep + governance semantics the benchmark runner cannot reach (idempotent + redelivery, UNKNOWN reconciliation, cancel/late-result isolation, lease + fencing). + +## Class coverage (official runner) + +| HarnessBench class | Tasks | Multi-round | Proven by | +|---|---|---|---| +| Long-running Autonomy & State Adaptation | 11 | 7 (007, 057, 058, 059, 060, 103, 105) | oracle + rubric + protocol smoke + audit (priority class) | +| Software Engineering & Codebase Maintenance | 22 | – | oracle + rubric | +| Workspace, Tool Use & Multimodal Operations | 15 | – | oracle + rubric; multimodal cases additionally need provider-side capabilities | +| Data, BI & Finance Analytics | 14 | – | oracle + rubric | +| Knowledge, Evidence & Retrieval | 13 | – | oracle + rubric | +| Office & Business Communication | 12 | – | oracle + rubric; browser/office tasks need optional local services | +| Vertical Professional Workflows | 12 | – | oracle + rubric | +| SRE, DevOps & Release Ops | 7 | – | oracle + rubric | + +Priority multi-round tasks for the harness 对照组: `057-interruption-resume`, +`058-multiday-project-state`, `059-event-update-replan`, +`105-partial-batch-resume-ledger`, `106-release-approval-gate-plan`. + +## Harness-internal capability provenance + +| Capability | Protocol smoke | Custom audit | Module suite | +|---|---|---|---| +| Runtime (dynamic) task creation, no pre-registered Task | ✔ scenario 1 | ✔ `dynamic-task` | `AgentHarnessTest` | +| Stable session/workspace across rounds, fresh JVM per round | ✔ scenarios 1–2 | ✔ `round-continuity` | `AgentHarnessTest` | +| Checkpoint persist + resume after bounded slice | ✔ scenario 3 | ✔ `checkpoint-record-exists` | `AgentHarnessTest` | +| Async tool → WAITING, durable open wait | ✔ scenario 4 | ✔ `open-wait-persisted` | `CodingAgentHarnessTest` | +| Provider failure → exit 1, FAILED surfaced | ✔ scenario 5 | – | – | +| Bare control: no durable state, transcript replay only | ✔ scenario 6 | ✔ honest `not exercised` | – | +| Duplicate delivery idempotency, late-result isolation | – | key-count exported; behavior owned by module suite | `HarnessGatewayInvariantTest` (idempotency + atomic waits), `AgentHarnessTest` (late async completion) | +| Approval/submission gates block completion | – | checked when gates present (`gate-before-complete`) | `AgentHarnessTest.approvalWaitIsDurable...` | +| UNKNOWN not blindly retried | – | checked when present (`unknown-preserved` rejects a later task/session execution); no direct module-suite owner yet | – | +| Cancel does not reopen after late async result | – | – | `AgentHarnessTest.cancelledTaskQuarantinesLateAsyncCompletion...` | +| Lease fencing / worker handoff | – | – | module suite only | + +## Not executed in this integration round + +- **Live model runs** (any class): require provider credentials; recorded as + environment blocker, not a skipped claim. Run with `ai4j-harness` / + `ai4j-bare` entries once `AI4J_BENCH_API_KEY` is available. +- **Docker-isolated tasks**: the runner's container path was not exercised; + the bridge runs on the host like other CLI adapters. +- **SIGKILL-mid-write journal recovery**: every bridge start exercises the + store's journal replay (`readRecovered`), but a hard-kill injection is owned + by `FileHarnessStore` tests, not by the benchmark surface. + +## Residual risks + +- generic_cli forwards `os.environ` + proxy env only; benchmark configuration + (mode, model, credentials) must be exported in the shell running + HarnessBench — the model_config `env` key is not forwarded by the runner. +- The bare mode's transcript-replay continuity is an honest but weak control: + it shows outcome deltas of harness mode, not a second implementation. +- `prompt-round{N}.txt` naming is parsed for the round number; if the runner + changes its naming scheme, `AI4J_BENCH_ROUND` must be set explicitly. diff --git a/benchmarks/harnessbench-ai4j/java/io/github/lnyocly/ai4j/harnessbench/HarnessBenchBridge.java b/benchmarks/harnessbench-ai4j/java/io/github/lnyocly/ai4j/harnessbench/HarnessBenchBridge.java new file mode 100644 index 00000000..98b15654 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/java/io/github/lnyocly/ai4j/harnessbench/HarnessBenchBridge.java @@ -0,0 +1,886 @@ +package io.github.lnyocly.ai4j.harnessbench; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; + +import io.github.lnyocly.ai4j.agent.Agent; +import io.github.lnyocly.ai4j.agent.AgentContext; +import io.github.lnyocly.ai4j.agent.AgentOptions; +import io.github.lnyocly.ai4j.agent.AgentRequest; +import io.github.lnyocly.ai4j.agent.AgentResult; +import io.github.lnyocly.ai4j.agent.memory.InMemoryAgentMemory; +import io.github.lnyocly.ai4j.agent.model.AgentModelClient; +import io.github.lnyocly.ai4j.agent.model.AgentModelResult; +import io.github.lnyocly.ai4j.agent.model.AgentModelStreamListener; +import io.github.lnyocly.ai4j.agent.model.AgentPrompt; +import io.github.lnyocly.ai4j.agent.model.ChatModelClient; +import io.github.lnyocly.ai4j.agent.runtime.ReActRuntime; +import io.github.lnyocly.ai4j.agent.tool.AgentToolCall; +import io.github.lnyocly.ai4j.agent.tool.AgentToolExecution; +import io.github.lnyocly.ai4j.agent.tool.AgentToolRegistry; +import io.github.lnyocly.ai4j.agent.tool.StaticToolRegistry; +import io.github.lnyocly.ai4j.agent.tool.AsyncToolExecutor; +import io.github.lnyocly.ai4j.agent.tool.ToolExecutor; +import io.github.lnyocly.ai4j.coding.CodingAgent; +import io.github.lnyocly.ai4j.coding.CodingAgentBuilder; +import io.github.lnyocly.ai4j.coding.CodingAgentHarness; +import io.github.lnyocly.ai4j.coding.CodingAgentOptions; +import io.github.lnyocly.ai4j.coding.CodingAgents; +import io.github.lnyocly.ai4j.coding.workspace.WorkspaceContext; +import io.github.lnyocly.ai4j.config.OpenAiConfig; +import io.github.lnyocly.ai4j.harness.CheckpointRecord; +import io.github.lnyocly.ai4j.harness.ExecutionRecord; +import io.github.lnyocly.ai4j.harness.FileHarnessConfig; +import io.github.lnyocly.ai4j.harness.FileHarnessStore; +import io.github.lnyocly.ai4j.harness.GateRecord; +import io.github.lnyocly.ai4j.harness.HarnessPersistence; +import io.github.lnyocly.ai4j.harness.HarnessRunBudget; +import io.github.lnyocly.ai4j.harness.HarnessRunRequest; +import io.github.lnyocly.ai4j.harness.HarnessRunResult; +import io.github.lnyocly.ai4j.harness.HarnessRunStatus; +import io.github.lnyocly.ai4j.harness.HarnessState; +import io.github.lnyocly.ai4j.harness.ReviewRecord; +import io.github.lnyocly.ai4j.harness.SubmissionRecord; +import io.github.lnyocly.ai4j.harness.TaskRecord; +import io.github.lnyocly.ai4j.harness.ToolInvocationRecord; +import io.github.lnyocly.ai4j.harness.WaitRecord; +import io.github.lnyocly.ai4j.harness.WakeupRecord; +import io.github.lnyocly.ai4j.platform.openai.chat.OpenAiChatService; +import io.github.lnyocly.ai4j.platform.openai.tool.Tool; +import io.github.lnyocly.ai4j.service.Configuration; +import io.github.lnyocly.ai4j.service.IChatService; + +import okhttp3.OkHttpClient; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * HarnessBench adapter bridge for the ai4j SDK. + * + *

Invoked once per benchmark round by the HarnessBench {@code generic_cli} + * adapter. All inputs arrive through {@code HARNESSBENCH_*} environment + * variables; benchmark configuration arrives through {@code AI4J_BENCH_*} + * variables. The bridge never reads provider secrets from the workspace and + * never writes into the source tree: durable state and audit artifacts live + * under the benchmark sandbox directory.

+ * + *

Modes (AI4J_BENCH_MODE):

+ *
    + *
  • {@code harness} (default): runs the round through a durable + * {@link CodingAgentHarness} keyed by the benchmark session id. Each + * round is a fresh JVM process; continuation across rounds and across + * processes happens through the file-backed harness persistence.
  • + *
  • {@code bare}: runs a plain {@link Agent} per round. Continuity is a + * JSONL transcript replay under the sandbox state dir, so the bare + * baseline intentionally lacks durable tasks, checkpoints, waits, + * gates, and audit state.
  • + *
+ * + *

Exit codes: {@code 0} the round completed (any status the runner may + * continue from, including WAITING/BLOCKED/IN_REVIEW), {@code 1} the round + * failed, {@code 2} the execution ended UNKNOWN/CANCELLED (operators must + * reconcile; the bridge refuses to blindly retry).

+ */ +public final class HarnessBenchBridge { + + private static final String AUDIT_SCHEMA = "ai4j-harness-audit/v1"; + private static final String TRACE_SCHEMA = "ai4j-execution-trace/v1"; + private static final int OUTPUT_PREVIEW_CHARS = 2000; + + public static void main(String[] args) { + BridgeConfig cfg = BridgeConfig.fromEnv(System.getenv()); + RunOutcome outcome; + try { + outcome = "bare".equalsIgnoreCase(cfg.mode) ? runBare(cfg) : runHarness(cfg); + } catch (BenchFailure failure) { + outcome = new RunOutcome("FAILED", failure.getMessage(), null, null, null, null); + } catch (Exception failure) { + outcome = new RunOutcome("FAILED", String.valueOf(failure), null, null, null, null); + } + AuditArtifacts artifacts = writeAudit(cfg, outcome); + JSONObject line = new JSONObject(); + line.put("mode", cfg.mode); + line.put("benchTaskId", cfg.benchTaskId); + line.put("sessionId", cfg.sessionId); + line.put("round", cfg.round); + line.put("status", outcome.status); + line.put("executionId", outcome.executionId); + line.put("taskId", outcome.taskId); + line.put("waitId", outcome.waitId); + line.put("error", outcome.error); + line.put("audit", artifacts.auditPath); + line.put("trace", artifacts.tracePath); + System.out.println(line.toJSONString()); + System.exit(exitCode(outcome.status)); + } + + static int exitCode(String status) { + if ("UNKNOWN".equals(status) || "CANCELLED".equals(status)) { + return 2; + } + return "FAILED".equals(status) ? 1 : 0; + } + + // ------------------------------------------------------------------ + // Harness mode + // ------------------------------------------------------------------ + + private static RunOutcome runHarness(BridgeConfig cfg) throws Exception { + Path stateDir = cfg.stateDir.resolve("harness"); + CodingAgent codingAgent = buildCodingAgent(cfg); + CodingAgentHarness harness = CodingAgentHarness.builder() + .codingAgent(codingAgent) + .persistence(HarnessPersistence.file(stateDir)) + .autoResume(cfg.autoResume) + .build(); + HarnessRunResult result; + try { + String prompt = readPrompt(cfg); + String idempotencyKey = cfg.benchTaskId + "-r" + cfg.round; + String taskId = resolveSessionTaskId(harness, cfg.sessionId); + HarnessRunRequest request; + if (cfg.maxSteps > 0) { + request = HarnessRunRequest.builder() + .taskId(taskId) + .sessionId(cfg.sessionId) + .idempotencyKey(idempotencyKey) + .input(prompt) + .budget(HarnessRunBudget.builder().maxSteps(cfg.maxSteps) + .maxWallTimeMillis(cfg.wallMillis).build()) + .build(); + } else { + request = HarnessRunRequest.builder() + .taskId(taskId) + .sessionId(cfg.sessionId) + .idempotencyKey(idempotencyKey) + .input(prompt) + .build(); + } + result = harness.run(request); + } finally { + harness.close(); + } + return new RunOutcome(String.valueOf(result.getStatus()), + result.getError(), + result.getExecution() == null ? null : result.getExecution().getExecutionId(), + result.getTask() == null ? null : result.getTask().getTaskId(), + result.getWaitId(), + result.getOutputText()); + } + + /** + * Multi-round benchmark prompts arrive in fresh processes under one bench + * session. If an earlier round already bound this session to a harness + * task (typically runtime-created), later rounds continue THAT task + * instead of drifting into session-only runs. + */ + private static String resolveSessionTaskId(CodingAgentHarness harness, String sessionId) { + ExecutionRecord best = null; + for (ExecutionRecord e : harness.getGateway().listExecutions()) { + if (!sessionId.equals(e.getSessionId()) || e.getTaskId() == null) { + continue; + } + if (best == null || e.getAttempt() >= best.getAttempt()) { + best = e; + } + } + if (best == null) { + return null; + } + TaskRecord task = harness.getGateway().getTask(best.getTaskId()); + if (task == null) { + return null; + } + switch (task.getStatus()) { + case DONE: + case CANCELLED: + return null; // terminal: let the round start fresh + case IN_REVIEW: + // Governance state: the kernel refuses new executions here. + // Continue the round session-scoped instead of bypassing the + // review; the IN_REVIEW task stays visible in the audit. + return null; + default: + return task.getTaskId(); + } + } + + private static CodingAgent buildCodingAgent(BridgeConfig cfg) { + ToolSupport tools = ToolSupport.fromConfig(cfg); + WorkspaceContext.WorkspaceContextBuilder workspace = WorkspaceContext.builder() + .rootPath(cfg.workspace.toString()); + // Caller write policy: protected path globs from the environment are + // appended to the workspace excluded paths so write/patch tools reject + // them with guidance (e.g. the task contract's "do not modify inputs"). + String protectedPaths = System.getenv("AI4J_BENCH_PROTECTED_PATHS"); + if (protectedPaths != null && !protectedPaths.trim().isEmpty()) { + List patterns = new ArrayList<>(WorkspaceContext.defaultExcludedPaths()); + for (String pattern : protectedPaths.split(",")) { + if (!pattern.trim().isEmpty()) { + patterns.add(pattern.trim()); + } + } + workspace.excludedPaths(patterns); + } + CodingAgentBuilder builder = CodingAgents.builder() + .modelClient(modelClient(cfg)) + .model(cfg.model) + .workspaceContext(workspace.build()); + if (cfg.reasoning != null) { + builder.reasoning(cfg.reasoning); + } + if (tools != null) { + // Scripted scenario: the scenario owns the single benchmark tool. + builder.codingOptions(CodingAgentOptions.builder() + .includeBuiltInTools(false) + .build()); + builder.toolRegistry(tools.registry).toolExecutor(tools.executor); + } + // Live runs keep the coding agent's built-in tools (read/write/bash + // and friends) so the model can actually operate on the workspace. + return builder.build(); + } + + // ------------------------------------------------------------------ + // Bare agent mode + // ------------------------------------------------------------------ + + private static RunOutcome runBare(BridgeConfig cfg) throws Exception { + ToolSupport tools = ToolSupport.fromConfig(cfg); + AgentContext context; + if (tools != null) { + context = AgentContext.builder() + .modelClient(modelClient(cfg)) + .memory(new InMemoryAgentMemory()) + .options(AgentOptions.builder().maxSteps(cfg.maxSteps > 0 ? cfg.maxSteps : 24).build()) + .model(cfg.model) + .toolRegistry(tools.registry) + .toolExecutor(tools.executor) + .build(); + } else { + context = AgentContext.builder() + .modelClient(modelClient(cfg)) + .memory(new InMemoryAgentMemory()) + .options(AgentOptions.builder().maxSteps(cfg.maxSteps > 0 ? cfg.maxSteps : 24).build()) + .model(cfg.model) + .build(); + } + Agent agent = new Agent(new ReActRuntime(), context, InMemoryAgentMemory::new); + + Path transcriptFile = cfg.stateDir.resolve("bare").resolve(cfg.sessionId + ".jsonl"); + String prompt = readPrompt(cfg); + String history = readTranscript(transcriptFile); + String input = history.isEmpty() + ? prompt + : "Previous rounds of this task (oldest first):\n" + history + + "\n\nCurrent task input:\n" + prompt; + AgentResult result = agent.run(AgentRequest.builder().input(input).build()); + + JSONObject entry = new JSONObject(); + entry.put("round", cfg.round); + entry.put("prompt", prompt); + entry.put("output", result.getOutputText()); + Files.createDirectories(transcriptFile.getParent()); + Files.write(transcriptFile, Collections.singletonList(entry.toJSONString()), + StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + + return new RunOutcome("BARE_" + result.getExecutionStatus(), null, null, null, + result.getWaitId(), result.getOutputText()); + } + + private static String readTranscript(Path file) throws IOException { + if (!Files.isRegularFile(file)) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + if (line.trim().isEmpty()) { + continue; + } + JSONObject row = JSON.parseObject(line); + sb.append("round ").append(row.getIntValue("round")).append(" prompt: ") + .append(row.getString("prompt")).append('\n'); + sb.append("round ").append(row.getIntValue("round")).append(" output: ") + .append(row.getString("output")).append('\n'); + } + return sb.toString().trim(); + } + + // ------------------------------------------------------------------ + // Model client selection + // ------------------------------------------------------------------ + + private static AgentModelClient modelClient(BridgeConfig cfg) { + if (cfg.scriptPath != null) { + return new ScriptedModelClient(cfg.scriptPath); + } + Configuration configuration = new Configuration(); + OpenAiConfig openAi = new OpenAiConfig(); + openAi.setApiKey(require(cfg.apiKey, "AI4J_BENCH_API_KEY")); + if (cfg.baseUrl != null && !cfg.baseUrl.trim().isEmpty()) { + openAi.setApiHost(cfg.baseUrl); + } + configuration.setOpenAiConfig(openAi); + configuration.setOkHttpClient(new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(600, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .build()); + IChatService chatService = new OpenAiChatService(configuration); + // Match the retry opportunity the stock agent CLIs have internally: + // gateways flake with transient 5xx; retry those, not real failures. + return new RetryingModelClient(new ChatModelClient(chatService), 3); + } + + /** Retries transient provider failures with backoff; surfaces the last error. */ + private static final class RetryingModelClient implements AgentModelClient { + private final AgentModelClient delegate; + private final int maxAttempts; + + RetryingModelClient(AgentModelClient delegate, int maxAttempts) { + this.delegate = delegate; + this.maxAttempts = maxAttempts; + } + + private static boolean isTransient(Throwable t) { + String m = String.valueOf(t); + return m.contains("temporarily unavailable") + || m.contains("Upstream service") + || m.contains("502") || m.contains("503") || m.contains("504") + || m.contains("timeout") || m.contains("Timeout"); + } + + @Override + public AgentModelResult create(AgentPrompt prompt) throws Exception { + Exception last = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return delegate.create(prompt); + } catch (Exception e) { + if (!isTransient(e) || attempt == maxAttempts) { + throw e; + } + last = e; + Thread.sleep(attempt * 5_000L); + } + } + throw last; + } + + @Override + public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) throws Exception { + return create(prompt); + } + } + + private static String require(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new BenchFailure("missing required environment variable " + name); + } + return value.trim(); + } + + // ------------------------------------------------------------------ + // Benchmark tool plumbing (scripted scenarios may declare one tool) + // ------------------------------------------------------------------ + + private static final class ToolSupport { + final AgentToolRegistry registry; + final ToolExecutor executor; + + private ToolSupport(AgentToolRegistry registry, ToolExecutor executor) { + this.registry = registry; + this.executor = executor; + } + + static ToolSupport fromConfig(BridgeConfig cfg) { + if (cfg.scriptPath == null) { + return null; // live runs use the model's own function-calling surface + } + JSONObject scenario = ScriptedModelClient.loadScenario(cfg.scriptPath); + JSONObject tool = scenario.getJSONObject("tool"); + if (tool == null) { + return null; + } + final String name = tool.getString("name"); + Tool.Function function = new Tool.Function(); + function.setName(name); + function.setDescription(tool.getString("description") == null + ? "HarnessBench scenario tool" : tool.getString("description")); + AgentToolRegistry registry = new StaticToolRegistry( + Collections.singletonList(new Tool("function", function))); + final String output = tool.getString("output"); + ToolExecutor executor; + if (tool.getBooleanValue("async")) { + executor = new AsyncToolExecutor() { + @Override + public AgentToolExecution start(AgentToolCall call) { + // Never completes: the round ends WAITING with a durable wait record. + return AgentToolExecution.pending("bench-op-" + name, null, + "async scenario operation pending"); + } + }; + } else { + executor = new ToolExecutor() { + @Override + public String execute(AgentToolCall call) { + return output == null ? "bench-tool-ok" : output; + } + }; + } + return new ToolSupport(registry, executor); + } + } + + /** Marker step that simulates a provider-level failure inside the model call. */ + private static final class FailStep { + final String text; + + FailStep(String text) { + this.text = text; + } + } + + /** Deterministic no-network model client driven by a scenario JSON file. */ + static final class ScriptedModelClient implements AgentModelClient { + + private final Iterator steps; + + ScriptedModelClient(Path scriptPath) { + this.steps = loadSteps(scriptPath); + } + + static JSONObject loadScenario(Path scriptPath) { + try { + return JSON.parseObject(new String(Files.readAllBytes(scriptPath), StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new BenchFailure("cannot read scenario script " + scriptPath + ": " + e.getMessage()); + } + } + + private static Iterator loadSteps(Path scriptPath) { + JSONObject scenario = loadScenario(scriptPath); + JSONArray steps = scenario.getJSONArray("steps"); + List results = new ArrayList(); + if (steps != null) { + for (int i = 0; i < steps.size(); i++) { + JSONObject step = steps.getJSONObject(i); + String type = step.getString("type"); + if ("tool".equals(type)) { + AgentToolCall call = AgentToolCall.builder() + .callId(step.getString("callId") == null ? "call-" + i : step.getString("callId")) + .name(step.getString("name")) + .arguments(step.getString("arguments") == null ? "{}" : step.getString("arguments")) + .type("function") + .build(); + results.add(AgentModelResult.builder() + .toolCalls(Collections.singletonList(call)) + .memoryItems(Collections.emptyList()) + .build()); + } else if ("fail".equals(type)) { + results.add(new FailStep(step.getString("text") == null + ? "scripted provider failure" : step.getString("text"))); + } else { + results.add(AgentModelResult.builder() + .outputText(step.getString("text") == null ? "done" : step.getString("text")) + .toolCalls(Collections.emptyList()) + .memoryItems(Collections.emptyList()) + .build()); + } + } + } + return results.iterator(); + } + + @Override + public AgentModelResult create(AgentPrompt prompt) { + if (!steps.hasNext()) { + return AgentModelResult.builder() + .outputText("script exhausted") + .toolCalls(Collections.emptyList()) + .memoryItems(Collections.emptyList()) + .build(); + } + Object next = steps.next(); + if (next instanceof FailStep) { + throw new IllegalStateException(((FailStep) next).text); + } + return (AgentModelResult) next; + } + + @Override + public AgentModelResult createStream(AgentPrompt prompt, AgentModelStreamListener listener) { + return create(prompt); + } + } + + // ------------------------------------------------------------------ + // Audit export + // ------------------------------------------------------------------ + + private static final class RunOutcome { + final String status; + final String error; + final String executionId; + final String taskId; + final String waitId; + final String outputText; + + RunOutcome(String status, String error, String executionId, String taskId, + String waitId, String outputText) { + this.status = status; + this.error = error; + this.executionId = executionId; + this.taskId = taskId; + this.waitId = waitId; + this.outputText = outputText; + } + } + + private static final class AuditArtifacts { + final String auditPath; + final String tracePath; + + AuditArtifacts(String auditPath, String tracePath) { + this.auditPath = auditPath; + this.tracePath = tracePath; + } + } + + private static AuditArtifacts writeAudit(BridgeConfig cfg, RunOutcome outcome) { + Path auditDir = cfg.auditDir; + try { + Files.createDirectories(auditDir); + JSONObject audit = new JSONObject(); + audit.put("schema", AUDIT_SCHEMA); + audit.put("benchTaskId", cfg.benchTaskId); + audit.put("sessionId", cfg.sessionId); + audit.put("round", cfg.round); + audit.put("mode", cfg.mode); + audit.put("modelId", cfg.modelId); + audit.put("workspace", cfg.workspace.toString()); + audit.put("status", outcome.status); + audit.put("error", outcome.error); + audit.put("outputPreview", preview(outcome.outputText)); + audit.put("state", projectState(cfg)); + Path auditFile = auditDir.resolve("harness_audit.json"); + Files.write(auditFile, JSON.toJSONString(audit, + com.alibaba.fastjson2.JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); + + JSONObject trace = new JSONObject(); + trace.put("schema", TRACE_SCHEMA); + trace.put("benchTaskId", cfg.benchTaskId); + trace.put("sessionId", cfg.sessionId); + trace.put("round", cfg.round); + trace.put("mode", cfg.mode); + trace.put("status", outcome.status); + trace.put("executionId", outcome.executionId); + trace.put("taskId", outcome.taskId); + trace.put("waitId", outcome.waitId); + trace.put("outputPreview", preview(outcome.outputText)); + trace.put("error", outcome.error); + Path traceFile = auditDir.resolve("execution_trace.json"); + Files.write(traceFile, JSON.toJSONString(trace, + com.alibaba.fastjson2.JSONWriter.Feature.PrettyFormat).getBytes(StandardCharsets.UTF_8)); + return new AuditArtifacts(auditFile.toString(), traceFile.toString()); + } catch (IOException e) { + throw new BenchFailure("cannot write audit artifacts under " + auditDir + ": " + e.getMessage(), e); + } + } + + /** Reads the durable store back from disk; never from the live object graph. */ + private static JSONObject projectState(BridgeConfig cfg) { + JSONObject state = new JSONObject(); + if (!"harness".equalsIgnoreCase(cfg.mode)) { + state.put("note", "bare mode has no durable harness state"); + return state; + } + Path stateDir = cfg.stateDir.resolve("harness"); + if (!Files.isDirectory(stateDir)) { + state.put("note", "harness state dir not created"); + return state; + } + FileHarnessStore store = new FileHarnessStore(new FileHarnessConfig(stateDir, "default")); + try { + HarnessState snapshot = store.load(); + state.put("harnessId", snapshot.getHarnessId()); + state.put("version", snapshot.getVersion()); + state.put("updatedAtEpochMs", snapshot.getUpdatedAtEpochMs()); + state.put("tasks", projectCollection(snapshot.getTasks(), new RecordProjector() { + @Override + public JSONObject apply(TaskRecord r) { + JSONObject o = new JSONObject(); + o.put("taskId", r.getTaskId()); + o.put("title", r.getTitle()); + o.put("status", String.valueOf(r.getStatus())); + o.put("createdBy", r.getCreatedBy()); + o.put("createdAtEpochMs", r.getCreatedAtEpochMs()); + return o; + } + })); + state.put("executions", projectCollection(snapshot.getExecutions(), new RecordProjector() { + @Override + public JSONObject apply(ExecutionRecord r) { + JSONObject o = new JSONObject(); + o.put("executionId", r.getExecutionId()); + o.put("taskId", r.getTaskId()); + o.put("sessionId", r.getSessionId()); + o.put("status", String.valueOf(r.getStatus())); + o.put("attempt", r.getAttempt()); + o.put("checkpointId", r.getCheckpointId()); + o.put("createdAtEpochMs", r.getCreatedAtEpochMs()); + o.put("startedAtEpochMs", r.getStartedAtEpochMs()); + o.put("finishedAtEpochMs", r.getFinishedAtEpochMs()); + o.put("updatedAtEpochMs", r.getUpdatedAtEpochMs()); + return o; + } + })); + state.put("checkpoints", projectCollection(snapshot.getCheckpoints(), new RecordProjector() { + @Override + public JSONObject apply(CheckpointRecord r) { + JSONObject o = new JSONObject(); + o.put("checkpointId", r.getCheckpointId()); + o.put("executionId", r.getExecutionId()); + o.put("createdAtEpochMs", r.getCreatedAtEpochMs()); + return o; + } + })); + state.put("waits", projectCollection(snapshot.getWaits(), new RecordProjector() { + @Override + public JSONObject apply(WaitRecord r) { + JSONObject o = new JSONObject(); + o.put("waitId", r.getWaitId()); + o.put("executionId", r.getExecutionId()); + o.put("type", String.valueOf(r.getType())); + o.put("status", String.valueOf(r.getStatus())); + o.put("operationId", r.getOperationId()); + return o; + } + })); + state.put("wakeups", projectCollection(snapshot.getWakeups(), new RecordProjector() { + @Override + public JSONObject apply(WakeupRecord r) { + JSONObject o = new JSONObject(); + o.put("wakeupId", r.getWakeupId()); + o.put("waitId", r.getWaitId()); + o.put("type", String.valueOf(r.getType())); + o.put("deliveredAtEpochMs", r.getDeliveredAtEpochMs()); + return o; + } + })); + state.put("gates", projectCollection(snapshot.getGates(), new RecordProjector() { + @Override + public JSONObject apply(GateRecord r) { + JSONObject o = new JSONObject(); + o.put("gateId", r.getGateId()); + o.put("taskId", r.getTaskId()); + o.put("name", r.getName()); + o.put("status", String.valueOf(r.getStatus())); + return o; + } + })); + state.put("submissions", projectCollection(snapshot.getSubmissions(), new RecordProjector() { + @Override + public JSONObject apply(SubmissionRecord r) { + JSONObject o = new JSONObject(); + o.put("submissionId", r.getSubmissionId()); + o.put("taskId", r.getTaskId()); + o.put("executionId", r.getExecutionId()); + o.put("createdAtEpochMs", r.getCreatedAtEpochMs()); + return o; + } + })); + state.put("reviews", projectCollection(snapshot.getReviews(), new RecordProjector() { + @Override + public JSONObject apply(ReviewRecord r) { + JSONObject o = new JSONObject(); + o.put("reviewId", r.getReviewId()); + o.put("taskId", r.getTaskId()); + o.put("verdict", String.valueOf(r.getVerdict())); + return o; + } + })); + state.put("toolInvocations", projectCollection(snapshot.getToolInvocations(), new RecordProjector() { + @Override + public JSONObject apply(ToolInvocationRecord r) { + JSONObject o = new JSONObject(); + o.put("invocationId", r.getInvocationId()); + o.put("toolName", r.getToolName()); + o.put("callId", r.getCallId()); + o.put("status", String.valueOf(r.getStatus())); + o.put("operationId", r.getOperationId()); + o.put("waitId", r.getWaitId()); + return o; + } + })); + state.put("idempotencyKeyCount", + snapshot.getIdempotency() == null ? 0 : snapshot.getIdempotency().size()); + state.put("sessionCount", + snapshot.getSessions() == null ? 0 : snapshot.getSessions().size()); + state.put("eventCount", + snapshot.getEvents() == null ? 0 : snapshot.getEvents().size()); + } finally { + store.close(); + } + return state; + } + + private interface RecordProjector { + JSONObject apply(T record); + } + + private static JSONArray projectCollection(Map records, RecordProjector projector) { + JSONArray array = new JSONArray(); + if (records != null) { + for (T record : records.values()) { + array.add(projector.apply(record)); + } + } + return array; + } + + private static String preview(String text) { + if (text == null) { + return null; + } + return text.length() <= OUTPUT_PREVIEW_CHARS ? text : text.substring(0, OUTPUT_PREVIEW_CHARS) + "..."; + } + + // ------------------------------------------------------------------ + // Configuration and helpers + // ------------------------------------------------------------------ + + private static final class BridgeConfig { + final String mode; + final boolean autoResume; + final int maxSteps; + final Path workspace; + final Path stateDir; + final Path auditDir; + final String sessionId; + final String benchTaskId; + final String modelId; + final int round; + final Path promptFile; + final String apiKey; + final String baseUrl; + final String model; + final String reasoning; + final long wallMillis; + final Path scriptPath; + + BridgeConfig(String mode, boolean autoResume, int maxSteps, Path workspace, Path stateDir, + Path auditDir, String sessionId, String benchTaskId, String modelId, int round, + Path promptFile, String apiKey, String baseUrl, String model, String reasoning, + long wallMillis, Path scriptPath) { + this.mode = mode; + this.autoResume = autoResume; + this.maxSteps = maxSteps; + this.workspace = workspace; + this.stateDir = stateDir; + this.auditDir = auditDir; + this.sessionId = sessionId; + this.benchTaskId = benchTaskId; + this.modelId = modelId; + this.round = round; + this.promptFile = promptFile; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.model = model; + this.reasoning = reasoning; + this.wallMillis = wallMillis; + this.scriptPath = scriptPath; + } + + static BridgeConfig fromEnv(Map env) { + String sandbox = env.get("HARNESSBENCH_SANDBOX"); + String workspace = env.get("HARNESSBENCH_WORKSPACE"); + String sessionId = env.get("HARNESSBENCH_SESSION_ID"); + String promptFile = env.get("HARNESSBENCH_PROMPT_FILE"); + if (sandbox == null || workspace == null || sessionId == null || promptFile == null) { + throw new BenchFailure("HARNESSBENCH_SANDBOX, HARNESSBENCH_WORKSPACE, " + + "HARNESSBENCH_SESSION_ID and HARNESSBENCH_PROMPT_FILE are required"); + } + Path sandboxDir = Paths.get(sandbox); + Path stateDir = Paths.get(envVal(env, "AI4J_BENCH_STATE_DIR", + sandboxDir.resolve("ai4j-state").toString())); + return new BridgeConfig( + envVal(env, "AI4J_BENCH_MODE", "harness"), + Boolean.parseBoolean(envVal(env, "AI4J_BENCH_AUTO_RESUME", "true")), + Integer.parseInt(envVal(env, "AI4J_BENCH_MAX_STEPS", "32")), + Paths.get(workspace), + stateDir, + Paths.get(envVal(env, "AI4J_BENCH_AUDIT_DIR", + sandboxDir.resolve("ai4j-audit").toString())), + sessionId, + env.get("HARNESSBENCH_TASK_ID"), + envVal(env, "HARNESSBENCH_MODEL_ID", "unknown"), + parseRound(envVal(env, "AI4J_BENCH_ROUND", roundFromPromptFile(promptFile))), + Paths.get(promptFile), + env.get("AI4J_BENCH_API_KEY"), + env.get("AI4J_BENCH_BASE_URL"), + envVal(env, "AI4J_BENCH_MODEL", "gpt-4o-mini"), + env.get("AI4J_BENCH_REASONING"), + Long.parseLong(envVal(env, "AI4J_BENCH_WALL_SECONDS", "900")) * 1000L, + env.get("AI4J_BENCH_SCRIPT") == null ? null : Paths.get(env.get("AI4J_BENCH_SCRIPT"))); + } + } + + /** Derives the round number from the runner's prompt-round{N}.txt naming. */ + private static String roundFromPromptFile(String promptFile) { + String name = new File(promptFile).getName(); + if (name.startsWith("prompt-round")) { + String rest = name.substring("prompt-round".length()); + int dot = rest.indexOf('.'); + if (dot > 0) { + return rest.substring(0, dot); + } + } + return "1"; + } + + private static int parseRound(String text) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException e) { + return 1; + } + } + + private static String envVal(Map env, String key, String fallback) { + String value = env.get(key); + return value == null || value.trim().isEmpty() ? fallback : value.trim(); + } + + private static String readPrompt(BridgeConfig cfg) throws IOException { + if (!Files.isRegularFile(cfg.promptFile)) { + throw new BenchFailure("prompt file not found: " + cfg.promptFile); + } + return new String(Files.readAllBytes(cfg.promptFile), StandardCharsets.UTF_8); + } + + static final class BenchFailure extends RuntimeException { + private static final long serialVersionUID = 1L; + + BenchFailure(String message) { + super(message); + } + + BenchFailure(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/benchmarks/harnessbench-ai4j/tests/run_protocol_tests.sh b/benchmarks/harnessbench-ai4j/tests/run_protocol_tests.sh new file mode 100644 index 00000000..52f868ac --- /dev/null +++ b/benchmarks/harnessbench-ai4j/tests/run_protocol_tests.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# No-model protocol regression for the ai4j HarnessBench bridge. +# +# Proves, without any provider credential: +# 1. env/parameter contract (HARNESSBENCH_* -> bridge config) +# 2. exit-code contract (0 completed/waiting, 1 failed) +# 3. runtime (dynamic) task creation through the harness management tool +# 4. cross-process continuation: each round is a fresh JVM; state comes +# back from the durable store, not process memory +# 5. bounded-slice checkpoints persist and resume +# 6. async tools end the round WAITING with a persisted open wait +# 7. bare-agent mode rounds and transcript replay +# plus audit exports that pass audit/check_audit.py invariants. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BENCH_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BRIDGE="$BENCH_ROOT/bin/ai4j-bridge.sh" +CHECKER="$BENCH_ROOT/audit/check_audit.py" +WORK="$(mktemp -d "${TMPDIR:-/tmp}/ai4j-harnessbench-test-XXXXXX")" + +PASS=0 +FAIL=0 + +# Windows-style path for JVM/python arguments (Git Bash/MSYS safe). +W() { cygpath -w "$1" 2>/dev/null || echo "$1"; } + +setup_env() { + local sandbox="$1" session="$2" task="$3" + mkdir -p "$sandbox/workspace/in" "$sandbox/workspace/out" + export HARNESSBENCH_SANDBOX="$(W "$sandbox")" + export HARNESSBENCH_WORKSPACE="$(W "$sandbox/workspace")" + export HARNESSBENCH_SESSION_ID="$session" + export HARNESSBENCH_TASK_ID="$task" + export HARNESSBENCH_MODEL_ID="script-test" +} + +run_round() { + local script="$1" round="$2" prompt="$3" + local pf="$WORK/prompt-round${round}.txt" + printf '%s' "$prompt" > "$pf" + export HARNESSBENCH_PROMPT_FILE="$(W "$pf")" + export AI4J_BENCH_ROUND="$round" + export AI4J_BENCH_SCRIPT="$(W "$script")" + bash "$BRIDGE" run +} + +expect() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo " PASS $label" + PASS=$((PASS + 1)) + else + echo " FAIL $label (expected [$expected], got [$actual])" + FAIL=$((FAIL + 1)) + fi +} + +status_of() { grep -o '"status":"[A-Z_]*"' | head -1 | cut -d'"' -f4; } +state_len() { python -c "import json,sys;d=json.load(open(sys.argv[1],encoding='utf-8'));print(len(d['state']['$2']))" "$(W "$1")"; } +check_ok() { python "$CHECKER" "$(W "$1")" ${2:+--expect-status "$2"}; } + +echo "== protocol regression (work root: $WORK)" + +# --- 1. dynamic task creation, single round ------------------------------- +S1="$WORK/s1" +setup_env "$S1" "sess-dynamic" "057-interruption-resume" +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-dynamic-task.json" 1 "do the discovered work") +CODE=$? +echo "-- scenario: dynamic task (exit=$CODE)" +expect "exit code 0" "0" "$CODE" +expect "status COMPLETED" "COMPLETED" "$(echo "$OUT" | status_of)" +check_ok "$S1" COMPLETED > "$WORK/check1.txt" 2>&1 +expect "audit invariants" "0" "$?" +if grep -q "PASS dynamic-task" "$WORK/check1.txt"; then + echo " PASS audit shows runtime-created task"; PASS=$((PASS + 1)) +else + echo " FAIL audit dynamic-task evidence missing"; cat "$WORK/check1.txt"; FAIL=$((FAIL + 1)) +fi + +# --- 2. cross-process continuation, two rounds, same session --------------- +S2="$WORK/s2" +setup_env "$S2" "sess-resume" "058-multiday-project-state" +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-dynamic-task.json" 1 "round one: start the project state") +CODE=$? +echo "-- scenario: cross-process resume round 1 (exit=$CODE)" +expect "round 1 exit code 0" "0" "$CODE" +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-round2.json" 2 "round two: continue the project state") +CODE=$? +echo "-- scenario: cross-process resume round 2 (exit=$CODE)" +expect "round 2 exit code 0" "0" "$CODE" +AUDIT2="$S2/ai4j-audit/harness_audit.json" +expect "store saw 2 executions" "2" "$(state_len "$AUDIT2" executions)" +BOUND=$(python -c "import json,sys;d=json.load(open(sys.argv[1],encoding='utf-8'));es=d['state']['executions'];print(','.join(sorted({e['taskId'] for e in es})) if all(e.get('taskId') for e in es) else 'unbound')" "$(W "$AUDIT2")") +TASK_RECORDS=$(python -c "import json,sys;d=json.load(open(sys.argv[1],encoding='utf-8'));print(len([t for t in d['state']['tasks'] if t.get('taskId')=='runtime-task-a']))" "$(W "$AUDIT2")") +if [ "$BOUND" = "runtime-task-a" ] && [ "$TASK_RECORDS" = "1" ]; then + echo " PASS both rounds continued task runtime-task-a from durable store"; PASS=$((PASS + 1)) +else + echo " FAIL rounds did not continue one durable task (bound=$BOUND, taskRecords=$TASK_RECORDS)"; FAIL=$((FAIL + 1)) +fi +check_ok "$S2" > "$WORK/check2.txt" 2>&1 +expect "round-2 audit invariants" "0" "$?" +if grep -q "PASS round-continuity" "$WORK/check2.txt"; then + echo " PASS audit round-continuity"; PASS=$((PASS + 1)) +else + echo " FAIL audit round-continuity missing"; cat "$WORK/check2.txt"; FAIL=$((FAIL + 1)) +fi + +# --- 3. bounded slice persists a checkpoint and resumes -------------------- +S3="$WORK/s3" +setup_env "$S3" "sess-slice" "105-partial-batch-resume-ledger" +export AI4J_BENCH_MAX_STEPS="1" +export AI4J_BENCH_AUTO_RESUME="false" +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-slice.json" 1 "process the batch with a one-step budget") +CODE=$? +echo "-- scenario: bounded slice (exit=$CODE)" +expect "slice reports CONTINUATION_REQUIRED" "CONTINUATION_REQUIRED" "$(echo "$OUT" | status_of)" +CP_COUNT=$(state_len "$S3/ai4j-audit/harness_audit.json" checkpoints) +if [ "$CP_COUNT" -ge 1 ] 2>/dev/null; then + echo " PASS checkpoint persisted ($CP_COUNT)"; PASS=$((PASS + 1)) +else + echo " FAIL no checkpoint after bounded slice"; FAIL=$((FAIL + 1)) +fi +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-round2.json" 2 "resume and finish") +CODE=$? +echo "-- scenario: resume after slice (exit=$CODE)" +expect "resume exit code 0" "0" "$CODE" +unset AI4J_BENCH_MAX_STEPS AI4J_BENCH_AUTO_RESUME +check_ok "$S3" > "$WORK/check3.txt" 2>&1 +expect "slice audit invariants" "0" "$?" + +# --- 4. async tool => WAITING with durable open wait ----------------------- +S4="$WORK/s4" +setup_env "$S4" "sess-async" "106-release-approval-gate-plan" +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-async-wait.json" 1 "start the remote release build") +CODE=$? +echo "-- scenario: async wait (exit=$CODE)" +expect "async round exit code 0" "0" "$CODE" +expect "status WAITING" "WAITING" "$(echo "$OUT" | status_of)" +check_ok "$S4" WAITING > "$WORK/check4.txt" 2>&1 +expect "wait audit invariants" "0" "$?" +if grep -q "PASS open-wait-persisted" "$WORK/check4.txt"; then + echo " PASS open wait persisted"; PASS=$((PASS + 1)) +else + echo " FAIL open wait not persisted"; cat "$WORK/check4.txt"; FAIL=$((FAIL + 1)) +fi + +# --- 5. provider failure => exit 1 ------------------------------------------ +S5="$WORK/s5" +setup_env "$S5" "sess-fail" "059-event-update-replan" +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-fail.json" 1 "this will fail") +CODE=$? +echo "-- scenario: provider failure (exit=$CODE)" +expect "failure exit code 1" "1" "$CODE" +expect "status FAILED" "FAILED" "$(echo "$OUT" | status_of)" + +# --- 6. bare mode rounds + transcript replay -------------------------------- +S6="$WORK/s6" +setup_env "$S6" "sess-bare" "004-meeting-summary" +export AI4J_BENCH_MODE="bare" +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-bare-text.json" 1 "summarize") +CODE=$? +OUT=$(run_round "$BENCH_ROOT/tests/scenarios/scenario-bare-text.json" 2 "continue") +CODE2=$? +unset AI4J_BENCH_MODE +echo "-- scenario: bare mode (exits=$CODE,$CODE2)" +expect "bare round 1 exit 0" "0" "$CODE" +expect "bare round 2 exit 0" "0" "$CODE2" +LINES=$(wc -l < "$S6/ai4j-state/bare/sess-bare.jsonl" | tr -d ' ') +expect "transcript has 2 rounds" "2" "$LINES" +check_ok "$S6" > "$WORK/check6.txt" 2>&1 +expect "bare audit reports no durable state" "0" "$?" +if grep -q "not exercised" "$WORK/check6.txt"; then + echo " PASS bare mode honestly reports no harness state"; PASS=$((PASS + 1)) +else + echo " FAIL bare audit check unexpected"; cat "$WORK/check6.txt"; FAIL=$((FAIL + 1)) +fi + +echo +echo "protocol regression: $PASS passed, $FAIL failed" +echo "work root kept for inspection: $WORK" +[ "$FAIL" = "0" ] diff --git a/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-async-wait.json b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-async-wait.json new file mode 100644 index 00000000..3ffb4fd7 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-async-wait.json @@ -0,0 +1,20 @@ +{ + "comment": "Async tool never completes: the round must end WAITING with a durable open wait record.", + "tool": { + "name": "bench_tool", + "description": "async benchmark tool", + "async": true + }, + "steps": [ + { + "type": "tool", + "name": "bench_tool", + "callId": "call-async-1", + "arguments": "{}" + }, + { + "type": "text", + "text": "should not be reached while the operation is pending" + } + ] +} diff --git a/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-bare-text.json b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-bare-text.json new file mode 100644 index 00000000..24cc9ded --- /dev/null +++ b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-bare-text.json @@ -0,0 +1,9 @@ +{ + "comment": "Bare mode: text-only answer per round; continuity comes from transcript replay only.", + "steps": [ + { + "type": "text", + "text": "bare round done" + } + ] +} diff --git a/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-dynamic-task.json b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-dynamic-task.json new file mode 100644 index 00000000..1f06a512 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-dynamic-task.json @@ -0,0 +1,15 @@ +{ + "comment": "Round 1 creates a task at runtime through the harness management tool, then finishes.", + "steps": [ + { + "type": "tool", + "name": "harness_task_manage", + "callId": "call-create-1", + "arguments": "{\"operation\":\"create\",\"taskId\":\"runtime-task-a\",\"title\":\"Discovered work\",\"goal\":\"Handle the benchmark input\"}" + }, + { + "type": "text", + "text": "task created and round completed" + } + ] +} diff --git a/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-fail.json b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-fail.json new file mode 100644 index 00000000..72d46746 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-fail.json @@ -0,0 +1,9 @@ +{ + "comment": "Provider-level failure: the bridge must exit non-zero with status FAILED.", + "steps": [ + { + "type": "fail", + "text": "scripted provider failure" + } + ] +} diff --git a/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-round2.json b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-round2.json new file mode 100644 index 00000000..06098d00 --- /dev/null +++ b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-round2.json @@ -0,0 +1,9 @@ +{ + "comment": "Second round in a fresh JVM for the same session; the model just finishes.", + "steps": [ + { + "type": "text", + "text": "round two completed with persisted context" + } + ] +} diff --git a/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-slice.json b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-slice.json new file mode 100644 index 00000000..12230a2d --- /dev/null +++ b/benchmarks/harnessbench-ai4j/tests/scenarios/scenario-slice.json @@ -0,0 +1,20 @@ +{ + "comment": "Bounded slice: one tool call then the step budget ends the slice; a checkpoint must persist so a later process can resume.", + "tool": { + "name": "bench_tool", + "description": "deterministic benchmark tool", + "output": "bench-tool-ok" + }, + "steps": [ + { + "type": "tool", + "name": "bench_tool", + "callId": "call-slice-1", + "arguments": "{}" + }, + { + "type": "text", + "text": "slice finished" + } + ] +} diff --git a/docs-site/docs/agent/harness-runtime.md b/docs-site/docs/agent/harness-runtime.md new file mode 100644 index 00000000..0cd45deb --- /dev/null +++ b/docs-site/docs/agent/harness-runtime.md @@ -0,0 +1,226 @@ +--- +title: Durable Agent Harness 运行时 +description: 使用 ai4j-harness 为已有 Agent 增加动态 Task、持久化 Execution、checkpoint、等待恢复、依赖和完成治理能力。 +tags: [concept, harness] +--- + +# Durable Agent Harness 运行时 + +`ai4j-harness` 是一个可选的 SDK 模块。它把 Harness Anything 的核心管理思想移植成 Java 运行时边界,但不引入 `ha` CLI,也不把具体业务流程写死在 SDK 中。 + +它的定位可以先用一句话概括: + +> `Agent` 负责一次切片内的思考和执行,`Harness` 负责跨请求、跨进程、跨时间的工作状态、恢复和治理。 + +## 1. 它不会替换已有 Agent + +没有配置 Harness 时,现有的 `Agent`、`AgentSession`、`ToolExecutor`、MCP、Function Call、Skill、A2A、Subagent、Agent Team、Memory、上下文压缩、Sandbox、Permission、Hook 和 Plugin 的行为保持不变。 + +启用 Harness 后,已有 Agent 仍然负责: + +| 已有 Agent 能力 | 责任边界 | +| --- | --- | +| 模型调用和协议适配 | `ai4j` / `ai4j-agent` | +| ReAct、CodeAct、Workflow 等单次运行策略 | `ai4j-agent` | +| 工具声明、MCP、Function Call、Skill | `ai4j-agent` 和业务侧 | +| Memory、上下文投影、上下文压缩 | `ai4j-agent` / `ai4j-coding` | +| Sandbox、Permission、Hook、Plugin、Subagent、Agent Team | 现有 Agent/Coding Runtime | +| 一次 Agent 调用在什么时候停止 | Agent 的 step、token 和 wall-clock 配置,加上本次 Harness budget | + +Harness 只在外层增加: + +| Harness 能力 | 作用 | +| --- | --- | +| Task | 运行时动态记录一项可持续工作的目标,不要求开发者预先声明固定任务清单 | +| Execution | 一次输入或一次恢复所对应的持久化执行实例 | +| Checkpoint | 保存跨切片恢复所需的 Agent/Adapter 状态和摘要 | +| Wait/Wakeup | 保存用户输入、审批、异步服务、外部事件或定时等待 | +| Lease/Fencing | 防止多个 worker 同时修改同一个执行或同一个 Session | +| Task relation | 表达父子任务和依赖 DAG,并拒绝循环依赖 | +| Fact/Decision/Evidence/Relation | 把长期工作的事实、决定、证据和关系变成可查询记录 | +| Submission/Review/Gate | 把“Agent 说完成了”和“系统允许完成”分开 | +| File/JDBC store | 把上述状态保存到磁盘或数据库,而不是只放 JVM 内存 | + +## 2. 核心对象关系 + +Harness deliberately 不把 Task 和 Session 强行绑定: + +```text +业务输入 message / 外部事件 / worker 调度 + | + v + HarnessRunRequest + | + v + Execution(一次切片) + | | + | +--> Session ID:恢复 Agent 上下文 + | + +--> Task ID:可选,运行中可以由 Agent 创建并绑定 + | + v + Agent 或 HarnessExecutionAdapter + | + v + 一个有边界的 Agent slice + | + v + checkpoint + durable outcome + wait/wakeup +``` + +这几个身份的区别很重要: + +| 身份 | 典型含义 | 是否必须绑定 | +| --- | --- | --- | +| Task | “完成退款调查”“维护支付模块”“制作一集短剧”这样的长期工作 | 一个 Task 可以有多个 Execution | +| Execution | 本次收到消息、恢复等待或 worker 继续运行的具体切片 | 一个 Execution 最多有一个当前 Task,但可以没有 Task | +| Agent Session | Agent 的 memory、event log 和 run identity | 一个 Session 可以参与多个独立 Execution | +| scopeKey | 在同一个 Harness ledger 内做租户、项目或工作空间分区 | 可选;它不是 Conversation 或 Session | + +因此,一条新的客服消息通常会创建一个新的 Execution;它可以复用客户的 Agent Session,但不会因为复用了 Session 就自动继承上一条消息的 Task。Coding Agent 的 Task 则通常属于项目,可以被不同 CLI、TUI、ACP 或后台 worker 的 Execution 继续处理。 + +## 3. 一次 `run` 如何工作 + +`AgentHarness.run(...)` 每次执行一个有边界的切片: + +1. 宿主把业务自己的输入对象放进 `HarnessRunRequest.input`,同时提供稳定的 `sessionId`、可选的 `taskId`、`scopeKey` 和消息级幂等键。 +2. Harness 创建或读取一个持久化 Execution。即使当前还没有 Task,也可以先记录一次输入和执行结果。 +3. Harness 获取 Execution lease,并为共享的 Agent Session 获取 session lease。 +4. Harness 恢复已有 checkpoint 或 Session snapshot,然后把管理工具和强制执行边界叠加到现有 Agent 上。 +5. Agent 在自己的模型、工具、MCP、Memory、压缩和权限语义内运行;它可以根据当前输入决定是否创建、拆分、更新或关联 Task。 +6. 切片结束后,Harness 原子保存 Agent/Adapter 状态、checkpoint、工具调用、等待状态和 Execution outcome。 +7. 宿主根据结果继续 `resume`、调用 `deliver`、等待外部事件,或者把可运行 Task 交给下一个 worker。 + +```java +// message、messageId、sessionId 都是业务侧自己的概念和字段。 +HarnessRunResult result = harness.run(HarnessRunRequest.builder() + .scopeKey("shop-A") + .sessionId("customer-A-agent-session") + .idempotencyKey("message:msg-1001") + .input(message) + .build()); + +if (result.getStatus() == HarnessRunStatus.WAITING) { + // 业务侧保存 result.getWaitId(),并把需要用户回答或正在处理的状态交给自己的渠道层。 + publishWaitingReply(result.getOutputText(), result.getWaitId()); +} else if (result.getStatus() == HarnessRunStatus.CONTINUATION_REQUIRED) { + // 还有工作但本次 slice 到达边界;可由 worker 继续,而不是把它当作完成。 + enqueueExecution(result.getExecution().getExecutionId()); +} +``` + +`message` 不需要实现 SDK 的固定接口。它可以是电商消息 DTO、HTTP 请求、事件对象、CLI prompt、工单对象或任意业务输入。Harness 只保存输入摘要和 Agent 能够恢复所需的运行状态;完整业务对象是否落库、如何脱敏,由业务系统决定。 + +## 4. Task 是运行时动态产生的 + +开发者不需要为“退款”“改地址”“查订单”分别写固定的 `TaskDefinition`,也不需要在应用启动时创建唯一 Task。 + +当本次输入尚未对应 Task 时,Agent 可以调用自动注入的: + +```text +harness_task_manage { + "operation": "create", + "title": "核实客户的退款请求", + "goal": "确认订单、退款资格和执行结果" +} +``` + +如果当前 Execution 没有 Task,第一次 `create` 会把新 Task 绑定到当前 Execution;之后 Agent 可以在同一次长期工作中: + +- `split` 出订单核验、政策核验、退款提交、结果通知等子任务; +- 用 `add_dependency` 表达“退款提交依赖订单核验”; +- 根据新事实 `update` Task 的目标和计划; +- 记录 Fact、Decision、Evidence; +- 在切片边界保存 checkpoint; +- 通过 `harness_submission_request` 提交外部审核,而不是自行宣布完成。 + +宿主只有在自己已经知道长期工作身份时才传 `taskId`,例如后台重试某个已知的退款工单或继续一个项目级编码 Task。Task 仍然可以有多个 Session 和多个 Execution。 + +## 5. Agent、宿主和 Harness 各自负责什么 + +### Agent 自己负责 + +- 理解输入并选择业务工具; +- 根据工作复杂度决定何时创建或拆分 Task; +- 把重要事实、决定和证据写入 Harness; +- 在需要用户、审批或外部系统时请求 Wait; +- 产出阶段性回答或提交材料; +- 在给定的 Agent step 边界内继续工作。 + +### 业务开发者负责 + +- 定义输入 DTO、输出 DTO、消息幂等键和外部事件格式; +- 决定客户、订单、工单、项目、仓库等实体如何关联到 `scopeKey`、Task metadata 或业务数据库; +- 实现业务 Tool 和异步服务调用; +- 决定哪些工具必须已有 Task、哪些工具需要审批; +- 决定一次 slice 的时间、轮次、token 和费用预算; +- 定义人工接管、会话关闭、重试和超时规则; +- 为完成提交配置 Gate,提供人工或系统审核入口; +- 选择 File 或 JDBC,并负责数据库、备份、worker、回调和运维。 + +### Harness Runtime 负责 + +- 原子保存和恢复长期状态; +- 对 Execution、Session 和工具调用做租约、幂等和并发隔离; +- 维护等待、唤醒、checkpoint、依赖、证据和完成门禁; +- 将过期租约导致的不确定结果标记为 `UNKNOWN`,不擅自假设外部副作用成功或失败; +- 在任务取消或人工接管后隔离迟到的 Agent/异步结果; +- 让旧 Agent 在未配置 Harness 时不受影响。 + +业务规则不要塞入 `HarnessTaskSpec` 或 `HarnessContract` 的固定字段。例如“24 小时关闭 Conversation”是客服系统规则,不是 Harness 的通用 Task 状态;Harness 只提供可持久化的 Wait、Execution 和事件记录,让业务规则可以在其上实现。 + +## 6. 配置一个 Harness + +标准 Agent 使用 `AgentHarness`: + +```java +AgentHarness harness = AgentHarness.builder() + .agent(existingAgent) // 现有 ai4j Agent,保留原来的能力装配 + .persistence(HarnessPersistence.file( + projectRoot.resolve(".ai4j/harness"))) + .contract(HarnessContract.builder() + .taskRequiredTool("submitRefund") + .approvalRequiredTool("submitRefund") + .build()) + .build(); + +try { + HarnessRunResult result = harness.run(HarnessRunRequest.builder() + .scopeKey("shop-A") + .sessionId("customer-A-agent-session") + .input(message) + .build()); +} finally { + // 长期服务中通常把 Harness 作为应用生命周期 bean,在停机时关闭一次。 + harness.close(); +} +``` + +`HarnessContract` 是治理规则,不是业务任务模板。上面的规则表达的是:没有 Task 不能调用 `submitRefund`,并且调用前需要审批;它没有规定 Task 的标题、订单字段或客服会话字段。 + +对于 `ai4j-coding`,使用 `CodingAgentHarness` 可以保留 workspace 工具、CodeAct、compact、进程、MCP、subagent 和现有审批语义,详见 [Coding Agent Harness 集成](/docs/products/coding-agent/harness-integration)。 + +## 7. 不引入 CLI + +Harness Anything 的 `ha` 命令适合人和外部 Coding Agent 操作项目治理目录;SDK 不复制这个 CLI。 + +SDK 提供的是: + +- Java `AgentHarness` / `CodingAgentHarness` 入口; +- Java `HarnessCommandGateway`,供宿主、worker、Webhook、人工后台和测试使用; +- 可选的 Harness Function Call 工具,让 Agent 能在运行中管理自己的 Task、事实和等待; +- File/JDBC 持久化实现。 + +开发者可以在自己的 HTTP 服务、消息消费者、CLI、TUI、后台 worker 或调度器中调用这些 API。这样同一个 Harness ledger 既可以被多个 Agent worker 共享,也可以由人工后台修改 Task 或投递 Wait,而不要求用户安装 `ha`。 + +## 8. 其它运行时 + +标准 `Agent` 和 `CodingAgent` 已经有直接适配器。业务如果有自己的不可替换运行时,可以实现 `HarnessExecutionAdapter`,只需要负责: + +- 根据 checkpoint 打开或恢复自己的运行态; +- 执行一个有边界的 slice; +- 导出可序列化的 Adapter state; +- 应用宿主投递的 Wait 结果。 + +Task、Execution、lease、wait、checkpoint、依赖、审查和完成门禁仍由 Harness 统一管理。这个扩展点是为了接入已有 runtime,不要求普通业务开发者额外实现一套 Agent。 + diff --git a/docs-site/docs/agent/harness-tools-and-persistence.md b/docs-site/docs/agent/harness-tools-and-persistence.md new file mode 100644 index 00000000..bd870133 --- /dev/null +++ b/docs-site/docs/agent/harness-tools-and-persistence.md @@ -0,0 +1,211 @@ +--- +title: Harness Tools 与持久化 +description: 说明 Harness Function Call、Command Gateway、异步工具调用、File/JDBC 持久化、租约和恢复边界。 +tags: [concept, harness] +--- + +# Harness Tools 与持久化 + +Harness 的管理能力确实会以 Function Call 的形式提供给 Agent,但 Function Call 只是 Agent 进入管理面的入口,不是最终的权限边界,也不是持久化本身。 + +完整链路是: + +```text +模型选择 Harness Function Call + | + v +HarnessToolRegistry(声明工具) + | + v +HarnessToolExecutor(路由、幂等、Task/审批/等待约束) + | + v +HarnessCommandGateway(唯一的持久化命令面) + | + v +FileHarnessStore 或 JdbcHarnessStore +``` + +业务工具也经过同一个 `HarnessToolExecutor`: + +```text +HarnessToolExecutor + ├─ harness_* 管理调用 -> HarnessManagementToolExecutor -> CommandGateway + └─ 业务 Tool 调用 -> 业务 ToolExecutor + └─ Invocation / Wait / Approval / UNKNOWN 记录 +``` + +## 1. 管理工具清单 + +Harness 自动向已有工具 Registry 添加以下保留名称。业务 Tool 不应使用这些名称。 + +| 工具 | 作用 | 典型操作 | +| --- | --- | --- | +| `harness_context_get` | 读取当前 Execution 的 Task、可运行 Task、Wait、Fact、Decision、Evidence 和工具调用 | 查看当前长期上下文 | +| `harness_task_manage` | 管理 Task 和依赖 | `create`、`split`、`update`、`transition`、`add_dependency`、`get`、`list`、`runnable` | +| `harness_fact_record` | 记录或失效一个带来源的 Fact | `record`、`invalidate` | +| `harness_decision_propose` | 提出或由有权限的非 Agent 角色解决 Decision | `propose`、`resolve` | +| `harness_evidence_record` | 记录模型、工具、测试、文件或外部系统产生的 Evidence | `record` | +| `harness_relation_manage` | 管理实体间的通用关系 | `create`、`add`、`get`、`list` | +| `harness_control_request` | 请求 checkpoint、用户输入、异步操作、外部事件或审批等待 | `checkpoint`、`wait`、`approval` | +| `harness_submission_request` | 把 Task 提交给外部审核 | `submit` | + +这些工具的参数是通用的。订单、客户、代码文件、短剧素材等业务对象应放在 Task metadata、Evidence contentRef、Relation metadata 或业务数据库中,而不是让 SDK 猜测业务字段。 + +## 2. Agent 会不会绕过 Harness + +需要区分两种情况: + +### 管理状态的写入 + +不会让 Agent 直接写 `HarnessStore`。管理 Function Call 经过 `HarnessManagementToolExecutor`,所有写入最后进入 `HarnessCommandGateway`。Gateway 会检查: + +- Task、Execution、Session 和 `scopeKey` 是否一致; +- 依赖是否形成环; +- Wait 是否属于当前 Execution; +- 幂等键是否已经使用; +- 当前 Actor 是否有审批、审核、完成或 reconciliation 权限; +- Task 是否已经处于终态; +- lease 和 fencing token 是否仍然有效。 + +默认情况下,Agent 可以提出 Fact、Decision、Evidence、Task 和 Submission,但不能批准自己的 Submission、完成自己的 Task,也不能替外部副作用做最终 reconciliation。 + +### Agent 是否一定会主动调用管理工具 + +不能把模型提示当成语义完整性证明。Harness 会通过 `HarnessPrompts` 告诉 Agent 在复杂工作开始时读取上下文并维护 Task,但模型仍可能漏记一个事实或选择不拆分任务。 + +因此,对关键业务应同时使用: + +1. `HarnessContract` 对关键业务 Tool 设置 `taskRequiredTool` 和 `approvalRequiredTool`; +2. 宿主在入口、Webhook 和人工后台使用 `HarnessCommandGateway` 做必要状态写入; +3. 用 Submission/Gate/外部审核决定是否允许完成; +4. 对有副作用的业务 API 使用外部 idempotency key,并在 `UNKNOWN` 时查询真实业务系统。 + +这保证了 Agent 不能通过“不调用某个管理工具”直接绕开真正的执行和完成边界,同时也承认 Harness 不可能从模型的自然语言中自动推断所有业务事实。 + +## 3. 异步 Function Call 的持久化流程 + +业务方可以继续实现同步 `ToolExecutor`;Harness 会把同步结果记录为已完成的 Tool Invocation。需要等待远程服务时,实现可选的 `AsyncToolExecutor`: + +```java +final class SubmitRefundExecutor implements AsyncToolExecutor { + @Override + public AgentToolExecution start(AgentToolCall call) { + String operationId = refundApi.submitAsync(call.getArguments()); + CompletableFuture completion = + refundApi.completion(operationId); + return AgentToolExecution.pending( + operationId, + null, + "退款申请已提交,等待支付系统结果", + 1000L, + completion); + } +} +``` + +在 Harness 边界内,实际顺序是: + +1. 为本次工具调用预留持久化 `ToolInvocation`; +2. 调用业务 `AsyncToolExecutor.start`,立即拿到 `operationId`; +3. 创建 `ASYNC_OPERATION` Wait 和 checkpoint; +4. Agent 返回 `WAITING`,当前 Execution 变为 `WAITING`; +5. 如果 CompletionStage 仍在当前进程中,Harness 可以自动接收完成; +6. 如果进程重启,业务 Webhook 根据保存的 `operationId` 找到 Wait,并调用 `harness.deliver(waitId, result)`; +7. Wait、Wakeup 和恢复后的 Agent/Adapter 状态先原子持久化,Execution 再变为 `READY`,随后继续一个新的 slice。 + +```java +HarnessRunResult waiting = harness.run(HarnessRunRequest.builder() + .scopeKey("shop-A") + .sessionId("customer-A-agent-session") + .input(customerMessage) + .build()); + +if (waiting.getStatus() == HarnessRunStatus.WAITING) { + saveOperationBinding(waiting.getOperationId(), waiting.getWaitId()); +} + +// 由支付系统 Webhook、消息队列消费者或人工后台执行;不依赖原来的 JVM Future。 +HarnessRunResult resumed = harness.deliver( + loadWaitIdByOperation(operationId), + refundResult); +``` + +多个并行异步工具调用会创建多个 Wait;只有全部 Wait 被投递后,Execution 才能继续。取消或人工接管后到达的迟异步结果不会重新打开已经取消的工作,而会被记录为隔离的迟到结果。 + +如果外部服务已经执行但 JVM 在记录结果前崩溃,Harness 会保留 `UNKNOWN`,不会自动重试一个可能造成重复扣款或重复退款的操作。业务方必须用 `operationId` 或外部系统查询接口做 reconciliation;这也是为什么重要业务 API 必须支持幂等。 + +审批也遵守同一条副作用边界:如果现有 Agent 的 Permission 层在 Harness 已预留 Invocation 后要求审批,Harness 会在一个事务中把该 Invocation 与 `APPROVAL` Wait 关联并置为 `WAITING`。批准后,恢复的 Agent 可以用新的 provider `callId` 重试;只有同一 Execution、同一工具、等价参数且没有歧义时才会重新使用原 Invocation,并在真正执行前原子地恢复为 `STARTED`。拒绝则记为失败,不会执行工具。 + +## 4. Wait 类型 + +| Wait 类型 | 谁投递 | 示例 | +| --- | --- | --- | +| `USER_INPUT` | 用户渠道层 | Agent 询问“要退款哪个订单?” | +| `ASYNC_OPERATION` | 外部服务 Webhook、消息队列或回调消费者 | 退款、支付、物流、远程构建 | +| `APPROVAL` | 人工或有权限的系统 Actor | 高风险退款、发布、推送 | +| `EXTERNAL_EVENT` | 业务事件消费者 | 库存变更、人工处理完毕、CI 事件 | +| `TIME` / `RETRY` | 业务调度器或定时 worker | 到期检查、退避重试 | + +“等待用户输入”与“客服 Conversation 仍然开放”不是同一个概念。Harness 只负责保存 Wait;客服业务决定如何把 Wait 映射到消息渠道,以及 Conversation 是否因为人工接管而永久停止机器人。 + +## 5. File 持久化 + +本地项目或单机 Coding Agent 可以使用: + +```java +HarnessPersistence persistence = HarnessPersistence.file( + projectRoot.resolve(".ai4j/harness")); + +AgentHarness harness = AgentHarness.builder() + .agent(agent) + .persistence(persistence) + .build(); +``` + +目录由 Harness 管理,主要包含: + +```text +.ai4j/harness/ + state.json # 当前完整状态快照 + journal.jsonl # append-only 恢复日志 + .lock # 跨进程文件锁 +``` + +File store 每次更新都先生成新版本、追加 journal、替换 snapshot,并在日志过大且快照安全后压缩日志。它适合项目目录内的长期本地工作流;多实例生产服务、网络文件系统和高并发跨主机 worker 应使用 JDBC 或业务方实现自己的 `HarnessStore`。 + +注意:仓库根目录的 `harness/` 是项目维护者使用的私有 Harness Anything 账本,`.harness/` 是其生成投影;它们不等于 SDK 运行时的 `.ai4j/harness/`,也不应互相读写。 + +## 6. JDBC 持久化 + +多实例服务使用: + +```java +HarnessPersistence persistence = HarnessPersistence.jdbc( + dataSource, + "shop-A-customer-support"); + +AgentHarness harness = AgentHarness.builder() + .agent(customerSupportAgent) + .persistence(persistence) + .build(); +``` + +两个参数的含义是: + +| 参数 | 含义 | +| --- | --- | +| `dataSource` | 业务应用提供的 JDBC `DataSource`,负责连接池、数据库地址、凭证和事务环境 | +| `harnessId` | 一个逻辑 Harness ledger 的稳定名称,用于在同一数据库中隔离不同项目、店铺或 Agent 系统 | + +`"shop-A-customer-support"` 不是 Conversation ID、Session ID,也不会自动创建客服规则。使用同一个 `harnessId` 的 worker 共享同一套 Task/Execution/Wait 状态;使用不同值则是不同的 ledger。ledger 内部还可以用 `scopeKey` 对店铺、项目或子系统进一步分区。 + +JDBC store 使用完整状态行和 journal 行,并在状态更新时使用事务、行锁和版本条件更新。应用应让所有需要共享长期状态的实例连接到同一个数据库和同一个 `harnessId`,并为数据库备份、清理策略、迁移窗口和连接池设置运维规则。 + +当前自动建表 DDL 使用 `TEXT` 保存 JSON 状态,这与 SDK 现有 `JdbcAgentMemory` 的约定一致,已在 H2 回归测试中验证,适合 MySQL、MariaDB、PostgreSQL、H2 和 SQLite 一类数据库。Oracle、SQL Server 或带有不同大字段类型的数据库不应仅凭 JDBC 连接成功就假定自动 DDL 可用;业务方应在目标数据库上预建等价的两张表,或为该数据库实现自己的 `HarnessStore`,并把 schema 初始化作为部署迁移的一部分。 + +## 7. 没有生产 In-Memory Harness Store + +Harness 的长期事实、Task、Execution、Wait、checkpoint、lease 和审计不能只放在 JVM 堆里,所以 SDK 不提供生产用的 `InMemoryHarnessStore`。 + +Agent 侧的 `AgentMemory` 仍然可以按现有 SDK 方式配置;它是模型上下文状态,不等于 Harness ledger。要实现长程恢复,必须让 Harness 使用 File、JDBC 或业务方实现的持久化 `HarnessStore`。测试也应使用临时 File store 或测试数据库,这样可以真正覆盖重启恢复、并发和 journal 行为。 diff --git a/docs-site/docs/integrations/solutions/long-running-customer-support.md b/docs-site/docs/integrations/solutions/long-running-customer-support.md new file mode 100644 index 00000000..5bc1525f --- /dev/null +++ b/docs-site/docs/integrations/solutions/long-running-customer-support.md @@ -0,0 +1,221 @@ +--- +title: 长程电商客服 Agent +description: 使用 ai4j Agent、Harness、异步业务工具和业务 Conversation 状态实现可恢复的售前售后客服。 +tags: [solution, harness, agent] +--- + +# 长程电商客服 Agent + +本方案以一个电子设备外设店铺为例:服务持续运行,客户 A、B、C 同时咨询售前、退款、改地址、补差价、物流和售后问题。每个客户的 Agent 上下文相互隔离;复杂售后任务可以跨多天、跨多个消息和外部系统继续。 + +这页的关键不是给 SDK 增加一个 `CustomerMessage` 固定模型,而是展示业务消息如何进入通用 Harness。 + +## 1. 四种状态不要混为一谈 + +客服系统至少有四个不同边界: + +| 对象 | 业务含义 | 谁负责生命周期 | +| --- | --- | --- | +| Conversation | 渠道上的一次客户会话,包含客服接待、人工接管、关闭等规则 | 客服业务系统 | +| Agent Session | 模型上下文和 Agent memory 的稳定身份 | Agent/Harness 配合,业务提供映射 | +| Harness Task | “完成退款申请”“处理换货”这样的可持续工作 | Harness 记录,业务定义关联方式,Agent 可运行时创建 | +| Harness Execution | 处理一条消息、一个 Webhook 或一次恢复的具体 slice | Harness Runtime | + +Conversation 不是 Agent Session。一个客户可以有多个 Conversation;一个 Conversation 也可能产生多个 Execution。一个退款 Task 还可能在 Conversation 被人工接管后由人工修改,再由系统做后续记录,但不应因此自动让机器人重新回复。 + +## 2. 业务入口是普通消息处理器 + +业务方定义自己的消息和 Conversation 表。例如: + +```java +public final class CustomerMessage { + private String messageId; + private String customerId; + private String conversationId; + private String text; + private long receivedAtEpochMs; + // channel、attachments、order references 等仍由业务自己定义。 +} +``` + +消息处理器是业务代码,不是 Harness API: + +```java +public Reply handle(CustomerMessage message) { + Conversation conversation = conversationStore.require(message.getConversationId()); + + // HANDOFF_HUMAN 是永久的 Conversation 业务状态,不是 Harness 的通用 Task 状态。 + if (conversation.isHumanHandoff() || conversation.isClosed()) { + return Reply.noBotResponse(); + } + + String sessionId = sessionMapping.agentSessionId(conversation, message); + String taskId = taskMapping.currentTaskId(conversation).orElse(null); + + HarnessRunRequest.Builder request = HarnessRunRequest.builder() + .scopeKey("shop:" + conversation.getShopId()) + .sessionId(sessionId) + .idempotencyKey("message:" + message.getMessageId()) + .input(message); + if (taskId != null) { + request.taskId(taskId); + } + + HarnessRunResult result = customerSupportHarness.run(request.build()); + return replyMapper.toReply(result, conversation); +} +``` + +这里没有把 `conversationId`、`customerId`、`orderId` 强行加进 SDK 的公共类型。业务只需把自己的消息对象放进 `input`,把自己选择的稳定 Session ID 和可选 Task ID 放进通用请求。 + +对于第一条消息,如果业务没有已知 Task,可以不设置 `taskId`。Agent 在识别出“这是一个需要持续处理的退款问题”后调用 `harness_task_manage(create)`,Harness 会把运行时创建的 Task 绑定到当前未绑定 Execution。开发者无需提前创建一个固定的“退款 Task”。 + +## 3. 启动一个长期运行的客服服务 + +Harness 应该作为服务生命周期对象创建一次,而不是每条消息创建一次: + +```java +Agent customerSupportAgent = existingCustomerSupportAgent(); + +HarnessContract supportContract = HarnessContract.builder() + .taskRequiredTool("submitRefund") + .approvalRequiredTool("submitRefund") + .taskRequiredTool("changeShippingAddress") + .build(); + +AgentHarness customerSupportHarness = AgentHarness.builder() + .agent(customerSupportAgent) + .persistence(HarnessPersistence.jdbc( + dataSource, + "shop-A-customer-support")) + .contract(supportContract) + .build(); +``` + +现有 Agent 的售前知识库检索、订单查询、物流查询、MCP、Skill、权限和上下文压缩继续由现有 Agent 配置负责。Harness 只负责让这些调用跨 Execution 可恢复、可审计并受 Task/Approval 边界约束。 + +## 4. 客户 A 的退款消息如何走完整链路 + +假设客户 A 发送:`“我的订单为什么还没有退款?”`,业务按上面的入口提交一条 `CustomerMessage`。 + +### 4.1 第一片:识别工作并询问订单 + +1. Harness 写入 `Execution-A1`,使用客户 A 的 Session ID;此时可以没有 Task。 +2. Agent 读取 `harness_context_get`,发现当前没有退款 Task。 +3. Agent 创建 Task:“核实客户的退款请求”,并将其绑定到 `Execution-A1`。 +4. Agent 调用订单查询业务 Tool。 +5. 如果订单不明确,Agent 通过已有的 `ask_user` 或 Host Input 能力请求用户选择订单;Harness 把它变成 `USER_INPUT` Wait,并保存 checkpoint。 +6. 本次结果是 `WAITING`,业务把问题发回渠道,同时保存 `waitId`。 + +此时“Agent 正在等待客户回答”不是“Conversation 被关闭”,也不是“Task 已完成”。 + +### 4.2 客户 A 回答后继续 + +渠道层收到客户 A 的下一条消息后,业务先根据 Conversation 的未完成 Wait 判断它是否是回答,例如选择 `order-42`: + +```java +HarnessRunResult resumed = customerSupportHarness.deliver( + conversationStore.openWaitId(conversationId), + "order-42"); +``` + +Harness 会把 Wait 标记为 `DELIVERED`,把回答写入对应的 checkpoint/Session 恢复状态,并运行下一个有边界的 slice。Agent 可以继续查询退款政策、检查资格,然后调用 `submitRefund`。 + +### 4.3 退款服务是异步的 + +如果支付系统返回异步 `operationId`,业务的 `AsyncToolExecutor` 立即返回 pending 结果。Harness 会持久化: + +```text +ToolInvocation(submitRefund, STARTED -> WAITING) +Execution-A2 = WAITING +Wait(type=ASYNC_OPERATION, operationId=refund-operation-42) +Checkpoint(Agent Session + 当前 Task 计划) +``` + +支付系统 Webhook 到达时,不需要原来的客服请求线程仍然存在: + +```java +String waitId = supportWaitStore.findByOperationId("refund-operation-42"); +HarnessRunResult afterPayment = customerSupportHarness.deliver( + waitId, + RefundResult.accepted("refund-9001")); +``` + +Agent 随后可以阶段性回复:“退款申请已经提交,是否还有其他包裹需要退回?”如果客户继续聊天,则是新的 Execution,但仍可复用业务选择的 Agent Session;退款 Task 是否继续关联,由业务和 Agent 根据上下文决定。 + +## 5. 多个客户并行与隔离 + +客户 A、B、C 可以共享一个店铺 Agent 配置和一个 JDBC Harness ledger,但必须提供不同的 Session ID: + +```text +scopeKey = shop:shop-A +sessionId = customer:customer-A:agent +sessionId = customer:customer-B:agent +sessionId = customer:customer-C:agent +``` + +不同客户的 Session snapshot 不共享 Agent memory。不同消息通常形成不同 Execution,因此 A 的新消息不会因为复用 Session 就继承 B 的 Task,也不会把 B 的消息投喂到 A 的上下文。 + +同一个 Session 的两个消息如果同时到达,Harness 的 session lease 会阻止两个 Agent slice 同时修改同一份 memory。业务入口应在 Conversation/Session 层维护消息顺序,冲突时排队或稍后重试;店铺内不同客户的 Session 可以并行运行。 + +## 6. 会话、Task 和人工接管 + +### Conversation 的 24 小时规则由业务实现 + +很多客服系统会在 24 小时无消息后关闭 Conversation,或者由人工主动关闭。这个计时和关闭规则属于业务的 Conversation 表,不属于 Harness Contract,也不应由 Harness 自动猜测。 + +### 人工接管是永久单向转换 + +人工接管表示从这一刻起当前 Conversation 不再由机器人回复。业务可以把 Conversation 状态设置为 `HANDOFF_HUMAN`,把后续消息直接路由到真人客服: + +```java +conversationStore.markHumanHandoff(conversationId, humanAgentId); +return Reply.noBotResponse(); +``` + +即使 Harness 中还有未完成 Task 或 open Wait,业务入口也不能再调用 `customerSupportHarness.run` 处理这个 Conversation。人工客服可以通过业务后台修改订单、补充事实、添加 Evidence、更新 Task 或关闭 Conversation;这些操作应使用有权限的 human/system Actor 和 `HarnessCommandGateway`,而不是模拟 Agent。 + +当 Conversation 被关闭后,它不会重新回到机器人。客户下次发起新的 Conversation 时,业务可以建立新的 Agent Session 或用新的 Conversation Session ID,并根据业务需要把已关闭工单的摘要作为新输入。是否复用客户级长期记忆,是业务的 Session 映射决策,不是 Harness 自动继承。 + +## 7. 客服限制如何配置 + +Harness 不写死“客服最多 10 轮”或“所有 Agent 最多运行 30 分钟”。限制由业务根据任务类型和服务目标选择: + +```java +HarnessRunBudget budget = supportBudgetPolicy.forMessage( + message, + currentTask, + conversation); + +HarnessRunResult result = customerSupportHarness.run( + HarnessRunRequest.builder() + .scopeKey("shop:" + shopId) + .sessionId(sessionId) + .taskId(taskId) + .idempotencyKey("message:" + message.getMessageId()) + .input(message) + .budget(budget) + .build()); +``` + +业务可以按自己的规则统计一个 Task 的累计耗时、澄清次数、工具失败次数、用户等待时长和风险等级。当规则认为 Agent 继续处理不合适时,业务触发人工接管;当只是一次 slice 到达边界时,则使用 `CONTINUATION_REQUIRED` 继续运行,而不是误转人工。 + +## 8. 这个方案解决什么,不解决什么 + +它解决: + +- 消息线程结束、服务重启或 worker 更换后仍能恢复 Agent 工作; +- 同一店铺多个客户的 Agent Session 隔离; +- 一个退款 Task 跨多个消息、异步支付和人工操作保存状态; +- 工具调用的等待、幂等、审批、UNKNOWN 和迟到结果处理; +- Agent 自主拆分任务并记录长期事实、决定和证据; +- Task 完成需要外部审核/Gate,而不是模型一句“完成了”。 + +它不替业务决定: + +- 什么是 Conversation、何时 24 小时关闭; +- 什么条件触发人工接管; +- 一个客户应该映射一个还是多个 Agent Session; +- 订单、退款、物流和客服系统的数据库结构; +- 真人操作后的业务退款、改址和工单流程。 + diff --git a/docs-site/docs/products/coding-agent/harness-integration.md b/docs-site/docs/products/coding-agent/harness-integration.md new file mode 100644 index 00000000..7e86e30e --- /dev/null +++ b/docs-site/docs/products/coding-agent/harness-integration.md @@ -0,0 +1,153 @@ +--- +title: Coding Agent Harness 集成 +description: 把现有 Coding Agent 接入持久化 Harness,让项目级 Task 跨 CLI、TUI、ACP、worker 和重启继续执行。 +tags: [coding-agent, harness] +--- + +# Coding Agent Harness 集成 + +这一集成面向类似 Codex 或 Claude Code 的 Coding Agent:用户输入一个复杂目标,Agent 自己分析、拆分、修改代码、运行测试、处理构建输出,并可以长时间跨进程维护一个大型代码库。 + +Harness 不会把 Coding Agent 变成另一个产品,也不要求 SDK 引入 Harness Anything 的 `ha` 命令。它只把现有 `CodingAgent` 放进统一的持久化 Task/Execution 外层。 + +## 1. 项目级 Task,不绑定 CLI Session + +Coding 场景中,正确的关系通常是: + +```text +project/repository + └─ Task: “完成支付模块重构” + ├─ Execution: 第一个 CLI/TUI 输入 + ├─ Execution: 后台 worker 继续 + ├─ Execution: 另一个 CLI session 恢复 + └─ Execution: 重启后的 Harness 恢复 + +Agent Session:保存某一个 Agent runtime 的上下文,可被多个 Execution 使用, +但它不是 Task 的所有者,也不是项目本身。 +``` + +用户可以新开终端、切换 TUI/ACP、让后台 worker 接手一个 READY Execution,而 Task 仍然属于项目工作空间。Harness 的 lease 和 fencing 防止两个 worker 同时推进同一个 Execution 或同一个 Session。 + +## 2. 最小接入 + +`CodingAgent` 的 workspace 工具、MCP、Skill、CodeAct、compact、进程注册表、subagent 和既有权限策略继续由 `ai4j-coding` 负责: + +```java +CodingAgent codingAgent = existingCodingAgent(projectRoot); + +CodingAgentHarness harness = CodingAgentHarness.builder() + .codingAgent(codingAgent) + .persistence(HarnessPersistence.file( + projectRoot.resolve(".ai4j/harness"))) + .contract(HarnessContract.builder() + // 是否要求审批、是否需要外部 review,由项目策略决定。 + .requiresApprovedReview(false) + .build()) + .autoResume(false) + .build(); + +HarnessRunResult first = harness.run(HarnessRunRequest.builder() + .scopeKey("repo:" + repositoryId) + .sessionId("coding-session:" + clientSessionId) + .idempotencyKey("prompt:" + promptId) + .input("实现支付模块重构,并运行相关测试") + .build()); +``` + +这里没有预先创建一个固定的 `Task`。第一片运行时,Agent 可以通过 `harness_task_manage(create)` 根据用户目标创建 Task,然后再拆分代码搜索、接口设计、实现、测试和文档等子任务。 + +如果 CLI 已经从项目数据库或用户选择中得知 Task ID,可以直接传 `taskId`;否则省略它,让 Agent 动态决定。 + +## 3. 长时间自主推进 + +一个 slice 结束并不等于项目任务完成。Coding 宿主可以在 `CONTINUATION_REQUIRED` 时恢复同一个 Execution: + +```java +HarnessRunResult current = first; +while (current.getStatus() == HarnessRunStatus.CONTINUATION_REQUIRED) { + current = harness.resume(current.getExecution().getExecutionId()); +} + +if (current.getStatus() == HarnessRunStatus.WAITING) { + // 等待用户审批、CI、远程构建或外部事件;不要 busy loop。 + publishCodingWait(current); +} +``` + +对于后台 worker,可以让它从持久化 ledger 中取可运行 Task: + +```java +List results = harness.runReady( + HarnessRunBudget.builder() + .maxExecutions(1) + .build()); +``` + +`HarnessRunBudget` 只控制本次调度切片,不给所有 Coding Agent 强加统一的 24 小时或轮次上限。项目可以把 Agent 的 `maxSteps`、wall-clock、token budget 设得很大,也可以让 worker 无限次处理 `CONTINUATION_REQUIRED`,直到 Task 的提交、Gate 和外部审核真正允许完成。 + +## 4. 长程代码任务的典型过程 + +以“维护一个大型仓库并重构支付模块”为例: + +1. 用户 prompt 进入新的 Execution,Agent 创建项目级 Task。 +2. Agent 调用 `harness_task_manage(split)`,建立架构分析、代码修改、测试和迁移文档子任务。 +3. 子任务之间用 `add_dependency` 建立 DAG,例如测试依赖实现,迁移文档依赖接口稳定。 +4. Agent 用 `harness_fact_record` 记录仓库约束,用 `harness_decision_propose` 记录技术取舍,用 `harness_evidence_record` 记录测试命令、构建结果和代码位置。 +5. CodeAct 或普通工具到达 Agent step 边界时,Harness 保存 Coding session state 和 checkpoint,之后继续同一 Task。 +6. 远程 CI、长时间构建或人工审批通过 `ASYNC_OPERATION` / `APPROVAL` Wait 进入恢复链,而不是让 CLI 线程一直阻塞。 +7. Agent 通过 `harness_submission_request` 提交改动、测试证据、已知缺口和残余风险。 +8. 人工或受信系统读取 Submission,执行 review 和 Gate;只有有权限的 Actor 才能完成 Task。 + +“测试通过”是 Evidence;“Agent 认为可以交付”是 Submission;“项目允许 Task 变成 DONE”是 Review/Gate 后的独立决定。这些概念不能用一条模型输出替代。 + +## 5. 多个 Coding 客户端共享一个项目 + +CLI、TUI、ACP 和后台 worker 都可以打开同一个 `.ai4j/harness`: + +```text +CLI/TUI/ACP prompt + | + +--> FileHarnessStore(project/.ai4j/harness) + | + +--> 同一项目 Task / Execution / checkpoint + +后台 worker --+ +另一个客户端 --+--> lease + fencing,只有合法持有者推进当前 Execution +``` + +如果需要跨主机或多实例协作,改用: + +```java +HarnessPersistence.jdbc(dataSource, "repository-coding"); +``` + +`harnessId` 是共享 ledger 的逻辑名称,不是一次 CLI session 的名称。每个客户端仍然可以有自己的 Agent Session;Task 不会因此被绑定到某一个客户端。 + +## 6. Coding Agent 的自主性和治理 + +Coding 项目可以选择尽量少的约束: + +```java +HarnessContract contract = HarnessContract.builder() + .requiresApprovedReview(false) + .build(); +``` + +这不会取消 checkpoint、lease、依赖、Evidence、UNKNOWN 和恢复能力;它只表示项目不要求每次 Submission 都有外部 review。相反,涉及 `git push`、生产发布、数据库迁移或密钥读取的项目可以把对应业务 Tool 设置为需要审批。 + +管理工具通过 Function Call 暴露给 Agent,但完成权限仍在 Gateway/Contract。Agent 不应拥有批准自己的 Submission、完成自己的 Task 或确认未知外部副作用的权限。项目宿主可以在自己的 review 服务中以 human/system Actor 调用 Gateway。 + +## 7. 与 Harness Anything 的关系 + +使用 Harness Anything 配合外部 Coding Agent 时,`ha` CLI、治理目录和 `AGENTS.md` 是 Agent 外部可见的项目管理面。SDK 集成不复制那套 CLI,而是把同样的核心行为放到 Java 运行时: + +| Harness Anything 使用方式 | SDK 对应方式 | +| --- | --- | +| Agent 通过 CLI 创建/更新 Task | Agent 调用 `harness_task_manage`,Gateway 持久化 | +| `harness/` 中保存 plan、fact、decision、evidence | File/JDBC Harness ledger 保存结构化记录和 checkpoint | +| CLI/外部 Agent 继续一个工作包 | `resume`、`deliver`、`runReady` 或业务 worker | +| 人工 review / 完成边界 | Submission、Review、Gate 和 Actor 权限 | +| 多个客户端在同一项目目录工作 | 共享 File/JDBC store,配合 lease/fencing | + +因此既有 Coding Agent 能力仍然是基础;Harness 只增加一层跨时间的可恢复管理,不把项目工作流写成 SDK 内部固定任务表。 + diff --git a/docs-site/sidebars.ts b/docs-site/sidebars.ts index f21e18b0..c030f0ac 100644 --- a/docs-site/sidebars.ts +++ b/docs-site/sidebars.ts @@ -127,6 +127,8 @@ const sidebars: SidebarsConfig = { 'agent/agent-concepts', 'agent/use-cases-and-paths', 'agent/architecture', + 'agent/harness-runtime', + 'agent/harness-tools-and-persistence', 'agent/session-runtime', 'agent/tools-and-registry', 'agent/skills', @@ -245,6 +247,7 @@ const sidebars: SidebarsConfig = { 'products/coding-agent/acp-integration', 'products/coding-agent/tui-customization', 'products/coding-agent/lifecycle-hooks', + 'products/coding-agent/harness-integration', ], }, { @@ -301,6 +304,7 @@ const sidebars: SidebarsConfig = { 'integrations/solutions/searxng-web-search', 'integrations/solutions/spi-dispatcher-connectionpool', 'integrations/solutions/mysql-dynamic-datasource', + 'integrations/solutions/long-running-customer-support', ], }, ], diff --git a/docs/05-TEST-QA/Cadence-Ledger.md b/docs/05-TEST-QA/Cadence-Ledger.md index ddf3453e..c08ae02b 100644 --- a/docs/05-TEST-QA/Cadence-Ledger.md +++ b/docs/05-TEST-QA/Cadence-Ledger.md @@ -1,6 +1,6 @@ # Cadence Ledger - ai4j-sdk -> Last updated: 2026-07-29 +> Last updated: 2026-08-30 > Defines which regression gates should be revisited when each repository surface changes. > Historical rows may retain the exact pre-HA command used as evidence; new work must use HA. @@ -17,11 +17,13 @@ | Change Scope | Required Local Gates | Live / Credential Gates | Cadence | Minimum Evidence Depth | Why | |--------------|----------------------|-------------------------|---------|------------------------|-----| -| PR to `dev` or `main` touching `pom.xml`, `ai4j-extension-api/**`, `ai4j-plugin-ask-user/**`, `ai4j/**`, `ai4j-agent/**`, `ai4j-coding/**`, `ai4j-cli/**`, `ai4j-spring-boot-starter/**`, `ai4j-flowgram-spring-boot-starter/**`, `ai4j-flowgram-demo/**`, or `ai4j-bom/**` | RG-010, RG-011, RG-001, RG-002, RG-003, RG-004, RG-005, RG-006, RG-007 | none by default | PR | L1/L2 | `.github/workflows/java-regression.yml` runs Java 8 package smoke plus module test matrix | +| PR to `dev` or `main` touching `pom.xml`, `ai4j-extension-api/**`, `ai4j-plugin-ask-user/**`, `ai4j/**`, `ai4j-agent/**`, `ai4j-harness/**`, `ai4j-coding/**`, `ai4j-cli/**`, `ai4j-spring-boot-starter/**`, `ai4j-flowgram-spring-boot-starter/**`, `ai4j-flowgram-demo/**`, or `ai4j-bom/**` | RG-010, RG-011, RG-001, RG-002, RG-003, RG-004, RG-005, RG-006, RG-007, RG-013 | none by default | PR | L1/L2 | `.github/workflows/java-regression.yml` runs Java 8 package smoke plus module test matrix | | `ai4j-extension-api/` manifest, discovery, enable/expose, extension resource, lifecycle hook, or capability contract changes | RG-010, RG-007 | none by default | touched-surface | L1 plus L2 when shared build changes | extension API contract plus cross-module packaging; third-party extension and lifecycle hook behavior must stay deterministic by default | | `ai4j-plugin-ask-user/` official plugin package changes | RG-011, RG-007 | none by default | touched-surface | L1 plus L2 when shared build changes | official sample plugin must remain a deterministic Java 8 Maven package and continue to demonstrate ServiceLoader, validator, tool, command, Skill, and Prompt contracts | | `ai4j/` provider, protocol, RAG, vector, MCP, image, audio, video, realtime, or agentflow connector changes | RG-001, RG-007 | LV-001 only when real provider behavior is in scope | touched-surface; opt-in-live if provider contract changed | L1 plus L3 when live is approved | core SDK contract plus cross-module packaging; usage-schema changes must cover synchronous and SSE cache-token parsing and raw provider detail retention without a provider credential | | `ai4j-agent/` workflow, memory, trace, subagent, team, lifecycle hook dispatch, approval/permission policy, Agent Blueprint, SandboxProvider, orchestration, or provider usage/cost accounting changes | RG-002, RG-007 | LV-002 only when live model behavior is in scope; LV-004 when real sandbox provider behavior is in scope | touched-surface; opt-in-live for provider runtime changes | L1 plus L3 when live is approved | runtime behavior plus dependency alignment; cache-bucket pricing must prove unknown-price propagation locally, while provider-neutral result propagation and raw provider details remain deterministic local contracts | +| `ai4j-harness/` durable state, Task/Execution lifecycle, dependency graph, lease/recovery, checkpoint, wait/wakeup, provenance, review/gate, or Harness Tool gateway changes | RG-013, RG-002, RG-007 | none by default; LV-002 only when a live model is explicitly part of the scenario | touched-surface; PR, merge-batch | L1 plus L2 when shared build changes | Harness owns durable long-running orchestration around an existing Agent; the gate must cover File/JDBC recovery, bounded slice/resume, lease fencing, dependency blocking, authority boundaries, and asynchronous Tool/CodeAct delivery without changing the legacy Agent path | +| `benchmarks/harnessbench-ai4j/` audit checker, protocol scenarios, or score coverage reporting | RG-014, RG-013 | no provider credential for checker/protocol unit tests; live benchmark runs remain opt-in | touched-surface; PR | L1; L2 protocol smoke when Bash is available | Audit claims must reject UNKNOWN retries, and documented coverage counts must match the task list; quality scores and process reliability remain separate signals | | `ai4j-agent/**/a2a/**` AgentCard, A2A client, A2A server, task state, or protocol test changes | RG-002, RG-012, RG-008 | official Python peer smoke only when standard A2A interoperability is claimed; no credential required | touched-surface; opt-in external peer | L1 plus L3 local peer when claimed | preserve legacy aliases plus the verified A2A 1.0 message, lifecycle, SSE, push-configuration, and standard-security contracts; external claims require bidirectional official-peer message and stream evidence, while local tests retain lifecycle/push/auth coverage | | `ai4j-coding/` tools, outer-loop, checkpoint, shell/apply-patch, compaction, or usage/cost aggregation changes | RG-003, RG-007 | LV-002 only when live coding-agent/provider orchestration is in scope | touched-surface; opt-in-live for provider runtime changes | L1 plus L3 when live is approved | coding runtime plus cross-module packaging; public builder forwarding and cross-turn unknown/currency behavior require deterministic local coverage | | `ai4j-cli/` CLI, TUI, ACP, session, provider/model command, or rendering changes | RG-004, RG-007 | LV-002 only when the CLI is validated against a real model/provider | touched-surface; opt-in-live if needed | L1 plus L3 when live is approved | host behavior plus packaged artifact alignment | @@ -30,9 +32,9 @@ | `ai4j-flowgram-demo/` backend changes | RG-006, RG-007 | LV-003 when demo scenario is in scope | touched-surface; opt-in-live for end-to-end demo | L1/L2 plus L4 when approved | demo backend consumes starter contracts and can affect web-demo integration | | `docs-site/` content, config, or workflow changes | RG-008 | none by default | touched-surface; PR/push workflow if configured path matches | L2 | docs-site build/type safety is the owning gate | | `ai4j-flowgram-webapp-demo/` frontend changes | RG-009 | LV-003 when paired with a real backend/demo scenario | touched-surface; PR/push workflow if configured path matches; opt-in-live for end-to-end demo | L2 plus L4 when approved | `.github/workflows/flowgram-webapp-regression.yml` runs test/lint/type/build and publishes stable aggregate `flowgram-webapp-regression`; browser scenario is separate evidence | -| root `pom.xml`, `ai4j-bom/`, shared build plugins, release/publishing logic | RG-010, RG-011, RG-001, RG-002, RG-003, RG-004, RG-005, RG-006, RG-007 | CR-001 for release candidate or publish validation | touched-surface, PR, merge-batch; opt-in-live for release | L1/L2 plus L3-L5 when approved | shared dependency, plugin, or publishing changes can break all Java surfaces | +| root `pom.xml`, `ai4j-bom/`, shared build plugins, release/publishing logic | RG-010, RG-011, RG-001, RG-002, RG-003, RG-004, RG-005, RG-006, RG-007, RG-013 | CR-001 for release candidate or publish validation | touched-surface, PR, merge-batch; opt-in-live for release | L1/L2 plus L3-L5 when approved | shared dependency, plugin, or publishing changes can break all Java surfaces | | `AGENTS.md`, `docs/11-REFERENCE/`, Regression SSoT, Cadence Ledger, task standards, or workflow governance changes | affected gates by manual review; no executable gate unless command behavior changes | none by default | touched-surface governance review | L0/L1 doc verification | governance docs alone do not imply code regression, but they must not drift from executable commands | -| any merge to `dev` or `main` | full local baseline RG-001 to RG-011 | only opt-in gates explicitly required by the merged task/release | merge-batch | L1/L2; L3-L5 only when approved | branch integration should refresh the full deterministic control surface | +| any merge to `dev` or `main` | full local baseline RG-001 to RG-013 | only opt-in gates explicitly required by the merged task/release | merge-batch | L1/L2; L3-L5 only when approved | branch integration should refresh the full deterministic control surface | ## Shared Regression Batch Log @@ -112,3 +114,5 @@ | SRB-073 | 2026-07-29 | provider cache usage and configurable cost accounting | preserve OpenAI Chat/Responses cache-write and reasoning buckets, Anthropic cache read/creation plus raw TTL/server-tool/geography/tier fields in sync/SSE flows, map the Anthropic Chat compatibility response, normalize agent/coding results, use caller-supplied pricing only, and keep aggregate costs unknown for unpriced, unlabeled, or currency-mismatched usage | RG-001 pass; RG-002 pass; RG-003 pass; RG-007 pass | focused core/agent/coding regressions passed 18/0/0, 28/0/0, and 10/0/0; broad affected-reactor test passed with core 222 (1 existing optional skip), agent 316 (8 existing optional skips), and coding 131 (2 existing optional skips); `mvn -DskipTests package` passed across all 12 reactor projects. No provider credential, model price, or local path is committed. | SRB-074 after the next executable/docs-site surface change | | SRB-074 | 2026-07-29 | P2-B nested Skill discovery and manual-only visibility | recursively discover nested `SKILL.md` / `skill.md` directories in stable path order, treat a directory containing a Skill file as a leaf, skip symlink roots/directories/files, retain `disable-model-invocation: true` in host-facing descriptors, and omit manual-only Skills from the automatic model catalog without changing Tool or MCP permissions | RG-001 pass; RG-003 pass; RG-007 pass; RG-008 pass | `mvn -pl ai4j -am "-Dtest=SkillsIChatServiceTest" -DskipTests=false "-DfailIfNoTests=false" "-Dsurefire.failIfNoSpecifiedTests=false" test` passed 7/0/0; `mvn -pl ai4j-coding -am "-Dtest=CodingSkillSupportTest" -DskipTests=false "-DfailIfNoTests=false" "-Dsurefire.failIfNoSpecifiedTests=false" test` passed 4/0/0; `mvn -pl ai4j-coding -am -DskipTests=false test` passed with extension API 26, core 225 (1 skipped), agent 316 (8 existing optional skips), and coding 132 (2 existing optional skips); `mvn -DskipTests package` passed across 12 reactor projects; `npm --prefix docs-site run typecheck` and `npm --prefix docs-site run build` passed. No provider credential was used. | SRB-075 after the next executable/docs-site surface change | | SRB-075 | 2026-08-06 | MCP Streamable HTTP safe negotiation and legacy session validation | prevent AUTO from treating bare HTTP 400, unknown modern versions, authentication failures, or proxy errors as legacy; require explicit JSON-RPC `-32601` discovery evidence for downgrade; require legacy session ID, `notifications/initialized`, and the negotiated protocol version before application traffic | RG-001 pass; RG-007 pass | `mvn -pl ai4j -am "-Dtest=McpClientProtocolNegotiationTest,StreamableHttpModernProtocolTest" "-DskipTests=false" "-DfailIfNoTests=false" "-Dsurefire.failIfNoSpecifiedTests=false" test` passed 27/0/0; `mvn -pl ai4j "-DskipTests=false" test` passed 267/0/0 with 1 existing optional skip; no provider credential was used | SRB-076 after the next executable/docs-site surface change | +| SRB-076 | 2026-08-29 | durable Agent Harness kernel and asynchronous Tool boundary | add the optional `ai4j-harness` module around the existing Agent, persist Task/Execution/Session snapshots through File/JDBC stores, enforce lease/fencing/recovery and dependency/review gates, and add additive async Function Call/CodeAct continuation contracts | RG-013 pass; RG-002 pass; RG-007 pass | `mvn -pl ai4j-harness -am -DskipTests=false test` passed with extension API 26, core 347 (1 existing optional skip), agent 395 (10 existing optional skips), and Harness 48 tests; `mvn -pl ai4j-coding -am -DskipTests=false test` passed with coding 132 (2 existing optional skips); `mvn -DskipTests package` passed across 13 reactor projects; no provider credential was used. Harness tests cover restart recovery, runtime Task creation, dependency cycles, lease expiry, review/gate authority, session isolation, bounded resume, async Function Call, nested CodeAct async bridge, cancellation quarantine after human handoff, coding-style checkpoint/resume after Harness reopen, same-Execution concurrency serialization, idempotency namespace isolation, cross-scope reference rejection, concurrent approval deduplication, and invalid outcome rejection. | SRB-077 after the next executable/docs-site surface change | +| SRB-077 | 2026-08-30 | Harness approval recovery after tool reservation | close the approval exception window after an existing Permission executor has caused a durable Tool Invocation reservation, preserve exactly-once side-effect behavior across approval delivery and provider call-id changes, and classify approval denial as a failed invocation | RG-013 pass; RG-002 pass | `mvn -pl ai4j-harness -am -DskipTests=false test` passed with extension API 26, core 347 (1 existing optional skip), agent 395 (10 existing optional skips), and Harness 50 tests; no provider credential was used. The focused approval tests cover atomic Invocation-to-Approval-Wait linking, `WAITING` to `STARTED` re-reservation, changed provider call ids, exactly-once replay, and denial output/state. | SRB-078 after the next executable/docs-site surface change | diff --git a/docs/05-TEST-QA/Regression-SSoT.md b/docs/05-TEST-QA/Regression-SSoT.md index 2631b761..6a45b065 100644 --- a/docs/05-TEST-QA/Regression-SSoT.md +++ b/docs/05-TEST-QA/Regression-SSoT.md @@ -1,6 +1,6 @@ # Regression SSoT - ai4j-sdk -> Last updated: 2026-07-29 +> Last updated: 2026-08-30 > Control tower for fixed regression surfaces in the `ai4j-sdk` monorepo. ## Regression Layers @@ -20,13 +20,15 @@ Default task closeout should cite `local-required` evidence. If a task needs a l | RG-010 | 🟢 | extension API module | `mvn -pl ai4j-extension-api -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-06-20 pass, 25 tests | manifest model, ServiceLoader discovery, explicit enable/expose gates, runtime inspection snapshot, capability validation, extension resource registry contracts, strict public ID/name validation, `ExtensionValidator` authoring checks, explicit command/Skill/Prompt/Guardrail allowlist, activation plan fail-fast behavior, and optional lifecycle hook registration/snapshot/inspection contracts | | RG-011 | 🟢 | official Ask User plugin module | `mvn -pl ai4j-plugin-ask-user -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-06-10 pass, 6 plugin tests plus extension API 19 tests | official sample plugin manifest, ServiceLoader discovery, validator contract, host-mediated `ask_user` tool envelope, `ask-user` command envelope, Skill / Prompt resource packaging, and compatibility with the explicit resource activation API | | RG-001 | 🟢 | core SDK module | `mvn -pl ai4j -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-08-06 pass: 267 tests, 1 existing optional skip | core provider adapters, RAG, MCP, vector, image/audio/video/music, realtime, agentflow contract tests; OpenAI Chat prompt/completion cache-write and reasoning details, Responses input/output cache-write and reasoning details, and Anthropic cache read/creation plus raw TTL/server-tool/geography/tier fields must survive synchronous and SSE parsing. MCP AUTO negotiation must downgrade only on explicit JSON-RPC method-not-found evidence; legacy Streamable HTTP sessions must validate session ID, initialization state, and negotiated protocol version. Provider-dependent tests are excluded from default runs by `LiveProviderTest` category | -| RG-002 | 🟢 | agent runtime module | `mvn -pl ai4j-agent -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-07-29 pass: 316 tests, 8 existing optional skips | agent runtime, workflow, memory, trace, subagent/team orchestration, extension lifecycle hook dispatch, tool approval / permission policy, Agent Blueprint, SandboxProvider, and A2A. `ModelUsage` normalizes provider input, cache read/write/creation, and reasoning buckets while raw provider detail fields remain inspectable; configured `TracePricingResolver` prices only fully known nonzero buckets, and trace aggregates remain unknown when any model span is unpriced, unlabeled, or uses an incompatible currency. | +| RG-002 | 🟢 | agent runtime module | `mvn -pl ai4j-agent -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-08-29 pass: 395 tests, 10 existing optional skips | agent runtime, workflow, memory, trace, subagent/team orchestration, extension lifecycle hook dispatch, tool approval / permission policy, Agent Blueprint, SandboxProvider, A2A, and additive asynchronous Tool execution status/operation identity. `ModelUsage` normalizes provider input, cache read/write/creation, and reasoning buckets while raw provider detail fields remain inspectable; configured `TracePricingResolver` prices only fully known nonzero buckets, and trace aggregates remain unknown when any model span is unpriced, unlabeled, or uses an incompatible currency. | | RG-012 | 🟢 | A2A 1.0 JSON-RPC and SSE interoperability | `mvn -f ai4j-agent/pom.xml "-Dtest=A2AOfficialJsonRpcTest" -DskipTests=false test` | touched A2A surface, PR | L1 contract tests plus L3 local external peer | 2026-07-28 pass: 6/0/0 against `a2a-sdk==1.1.0` | verifies standard AgentCard `supportedInterfaces`, `A2A-Version: 1.0`, root `SendMessage`, `SendStreamingMessage` `StreamResponse` envelopes, terminal task/artifact parsing, and legacy fallback. Four opt-in properties/tests run both message and stream directions with a pinned official Python peer. Local `A2AServerTest` covers task lifecycle, push configuration, SSRF policy, and standard API-key/Bearer schemes; third-party task-management/push behavior and restart durability remain outside this gate. | -| RG-003 | 🟢 | coding runtime module | `mvn -pl ai4j-coding -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-07-29 pass: 131 tests, 2 existing optional skips | coding runtime, tools, outer loop, checkpoint, shell/apply-patch, skill resources, subagent handoff consumption, and P3 `bash exec` sandbox routing. `CodingAgentResult` carries all provider-neutral usage and configured cost buckets; loop aggregation keeps costs `null` for unpriced usage, unlabeled currencies, or mixed currencies, while preserving legacy constructors. | +| RG-013 | 🟢 | durable Agent Harness module | `mvn -pl ai4j-harness -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-08-30 pass: 50 tests, 0 failures, 0 errors, 0 skips | durable File/JDBC state and journal recovery; runtime-created and split Tasks; dependency graph satisfaction and cycle rejection; Execution slices, lease fencing, expiry-to-`UNKNOWN` reconciliation; Checkpoint and Wait/Wakeup delivery; Fact/Decision/Evidence provenance; Submission/Review/Gate completion boundary; session isolation and snapshot resume; Harness management Tool gateway; asynchronous Function Call and nested CodeAct bridge; cancelled-task quarantine after human handoff, cross-restart coding-style checkpoint resume, same-Execution local concurrency serialization, idempotency namespace isolation, cross-scope reference rejection, concurrent approval deduplication, permission approval after invocation reservation with changed provider call id, rejection handling, and invalid outcome rejection. Completion and approval remain outside the Agent authority boundary. | +| RG-014 | 🟢 | HarnessBench audit semantics | `python -m unittest discover -s benchmarks/harnessbench-ai4j/audit -p 'test_*.py'` | touched-surface, PR | L1 tests | 2026-09-09 pass: 2 tests | preserves `UNKNOWN` for reconciliation and fails when a later task/session execution or higher attempt indicates an automatic retry; validates benchmark audit checker behavior without provider credentials. | +| RG-003 | 🟢 | coding runtime module | `mvn -pl ai4j-coding -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-08-29 pass: 132 tests, 2 existing optional skips | coding runtime, tools, outer loop, checkpoint, shell/apply-patch, skill resources, subagent handoff consumption, and P3 `bash exec` sandbox routing. `CodingAgentResult` carries all provider-neutral usage and configured cost buckets; loop aggregation keeps costs `null` for unpriced usage, unlabeled currencies, or mixed currencies, while preserving legacy constructors. | | RG-004 | 🟢 | CLI/TUI/ACP host | `mvn -pl ai4j-cli -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-06-22 P4 pass, 298 CLI tests | terminal host, session runtime, ACP, rendering, provider/model command behavior with fake/local clients; includes P1-C `ai4j-cli run ` Blueprint runner and P4 `/sandbox status|enable daytona|attach daytona|disable` runtime binding. Targeted `mvn -pl ai4j-cli -am "-Dtest=SlashCommandControllerTest,CodingCliSessionRunnerArgumentParsingTest,CliSandboxCommandTest,CliSandboxSessionResolverTest,CodingCliSessionRunnerSandboxTest" -DskipTests=false -DfailIfNoTests=false test` passed with 61 tests; broad `mvn -pl ai4j-cli -am -DskipTests=false test` passed with extension API 25, core 103, agent 124, coding 61, cli 298 tests. Daytona live rerun was skipped because current shell env had no `DAYTONA_API_KEY`; LV-004 prior live smoke remains the opt-in sandbox-provider evidence. | | RG-005 | 🟢 | Spring Boot starter | `mvn -pl ai4j-spring-boot-starter -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-07-03 Suno config pass, 12 starter tests plus upstream gates | auto-configuration and config binding; includes single-instance and multi-instance OpenAI `videoUrl` binding, Suno `ai.suno.*` / `ai.platforms[].platform=suno` binding, first-chat, AgentFlow, and `ExtensionAutoConfigurationTest` for `ai.extensions.enabled`, `ai.extensions.tools.expose`, `ai.extensions.explicit-resource-activation`, and command/Skill/Prompt/Guardrail allow configuration | | RG-006 | 🟢 | FlowGram starter and task APIs | `mvn -pl ai4j-flowgram-spring-boot-starter -am -DskipTests=false test` | touched-surface, PR, merge-batch | L1 tests | 2026-06-09 pass | FlowGram runtime facade, controller, task store, trace bridge; local starter/demo gates passed and remote Java regression run `27202972949` includes `ai4j-flowgram-spring-boot-starter` plus `ai4j-flowgram-demo` | -| RG-007 | 🟢 | monorepo package build | `mvn -DskipTests package` | PR, merge-batch, shared build change | L2 local_smoke | 2026-07-29 provider cache usage package pass, 12 reactor projects | cross-module packaging and dependency alignment across 12 reactor projects: root plus 11 modules, including extension activation plan API, CLI extension UX, Spring starter binding, official Ask User plugin module, F-041 `extension check` gate wiring, OpenAI-compatible video service, Suno music service, RAG query planner per-strategy prompt compile/package compatibility, HybridRetriever fallback compile/package compatibility, `RagQuery.history`/`ChatMemoryItem` compatibility, RAG generation usage trace DTO compatibility, agent trace capture/replay output/error compatibility, and `CodingAgentResult` cache usage fields with legacy constructor compatibility | +| RG-007 | 🟢 | monorepo package build | `mvn -DskipTests package` | PR, merge-batch, shared build change | L2 local_smoke | 2026-08-29 Harness reactor package pass, 13 reactor projects | cross-module packaging and dependency alignment across 13 reactor projects: root plus 12 modules, including the durable `ai4j-harness` module and additive asynchronous Agent Tool contracts, alongside extension activation plan API, CLI extension UX, Spring starter binding, official Ask User plugin module, F-041 `extension check` gate wiring, OpenAI-compatible video service, Suno music service, RAG query planner per-strategy prompt compile/package compatibility, HybridRetriever fallback compile/package compatibility, `RagQuery.history`/`ChatMemoryItem` compatibility, RAG generation usage trace DTO compatibility, agent trace capture/replay output/error compatibility, and `CodingAgentResult` cache usage fields with legacy constructor compatibility | | RG-008 | 🟢 | docs-site build | `npm run typecheck`, then `npm run build` in `docs-site/` | touched-surface, docs PR/push, merge-batch | L2 local_smoke | 2026-07-28 A2A lifecycle and external-SDK docs pass | `npm --prefix docs-site run typecheck` and `npm --prefix docs-site run build` passed; the A2A page documents task lifecycle, SSE, push SSRF policy, standard API-key/Bearer metadata, external SDK validation, and explicit in-memory/third-party coverage limits. | | RG-009 | 🟢 | FlowGram webapp demo build and test | `npm run test`, `npm run lint`, `npm run ts-check`, then `npm run build` in `ai4j-flowgram-webapp-demo/` | touched-surface, PR, merge-batch | L2 local_smoke | 2026-06-10 local and remote pass | `npm test` now runs deterministic backend workflow normalization tests before lint/type/build; local `npm run test`, `npm run lint`, `npm run ts-check`, and `npm run build` passed. GitHub Actions `flowgram-webapp-regression` run `27253773916` passed on `main@b0993f56` with `detect-webapp-changes`, `webapp-checks` steps `Test` / `Lint` / `Typecheck` / `Build`, and aggregate `flowgram-webapp-regression` all successful | diff --git a/pom.xml b/pom.xml index 3a47a8d0..5b8ed3ca 100644 --- a/pom.xml +++ b/pom.xml @@ -93,6 +93,7 @@ ai4j ai4j-document-tika ai4j-agent + ai4j-harness ai4j-coding ai4j-cli ai4j-spring-boot-starter