diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4af64a53 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +ui/src/api/generated/** -whitespace diff --git a/api-docs/openapi/v3_0/aiFoundationApis.json b/api-docs/openapi/v3_0/aiFoundationApis.json index 62485e7b..2fb5e89c 100644 --- a/api-docs/openapi/v3_0/aiFoundationApis.json +++ b/api-docs/openapi/v3_0/aiFoundationApis.json @@ -2,10 +2,10 @@ "openapi" : "3.0.1", "info" : { "title" : "Halo", - "version" : "2.25.2" + "version" : "2.25.4" }, "servers" : [ { - "url" : "http://localhost:41657", + "url" : "http://localhost:33894", "description" : "Generated server url" } ], "security" : [ { @@ -735,6 +735,13 @@ "schema" : { "type" : "boolean" } + }, { + "description" : "Whether to inject the console-only lifecycle-aware tool for streamed tool-input diagnostics.", + "in" : "query", + "name" : "enableToolInputStreamTest", + "schema" : { + "type" : "boolean" + } } ], "requestBody" : { "content" : { @@ -2690,6 +2697,46 @@ } } }, + "TestAgentOptions" : { + "type" : "object", + "properties" : { + "approvalRequired" : { + "type" : "boolean" + }, + "browserToolEnabled" : { + "type" : "boolean" + }, + "enabled" : { + "type" : "boolean" + }, + "externalToolEnabled" : { + "type" : "boolean" + }, + "maxSteps" : { + "type" : "integer", + "format" : "int32" + }, + "profile" : { + "type" : "string", + "enum" : [ "BALANCED", "CONCISE", "EXPLICIT" ] + }, + "recoveryScenario" : { + "type" : "string", + "enum" : [ "NONE", "INVALID_INPUT", "RENAMED_TOOL", "FAILED_RECOVERY" ] + }, + "serverToolEnabled" : { + "type" : "boolean" + }, + "stepPolicy" : { + "type" : "string", + "enum" : [ "ALL_TOOLS", "SERVER_THEN_ALL", "SERVER_THEN_BROWSER" ] + }, + "toolInputStreamEnabled" : { + "type" : "boolean" + } + }, + "description" : "Agent workbench execution and diagnostic options." + }, "TestCompletionStreamRequest" : { "type" : "object", "properties" : { @@ -3368,6 +3415,9 @@ "TestUiMessageChatRequest" : { "type" : "object", "properties" : { + "agent" : { + "$ref" : "#/components/schemas/TestAgentOptions" + }, "context" : { "type" : "object", "additionalProperties" : { diff --git a/api/src/main/java/run/halo/aifoundation/agent/Agent.java b/api/src/main/java/run/halo/aifoundation/agent/Agent.java new file mode 100644 index 00000000..3d546461 --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/Agent.java @@ -0,0 +1,522 @@ +package run.halo.aifoundation.agent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.aifoundation.chat.GenerateTextRequest; +import run.halo.aifoundation.chat.GenerateTextResult; +import run.halo.aifoundation.chat.GenerationTimeouts; +import run.halo.aifoundation.chat.LanguageModel; +import run.halo.aifoundation.chat.PreparedStep; +import run.halo.aifoundation.chat.PrepareStepCallback; +import run.halo.aifoundation.chat.ReasoningOptions; +import run.halo.aifoundation.chat.StopCondition; +import run.halo.aifoundation.chat.StreamTextResult; +import run.halo.aifoundation.chat.middleware.LanguageModelMiddleware; +import run.halo.aifoundation.chat.middleware.LanguageModelMiddlewares; +import run.halo.aifoundation.exception.AiGenerationCancelledException; +import run.halo.aifoundation.lifecycle.GenerationErrorEvent; +import run.halo.aifoundation.lifecycle.GenerationFinishEvent; +import run.halo.aifoundation.lifecycle.GenerationLifecycle; +import run.halo.aifoundation.lifecycle.GenerationStartEvent; +import run.halo.aifoundation.lifecycle.GenerationStepFinishEvent; +import run.halo.aifoundation.lifecycle.GenerationStepStartEvent; +import run.halo.aifoundation.lifecycle.GenerationToolApprovalRequestEvent; +import run.halo.aifoundation.lifecycle.GenerationToolCallFinishEvent; +import run.halo.aifoundation.lifecycle.GenerationToolCallStartEvent; +import run.halo.aifoundation.schema.OutputSpec; +import run.halo.aifoundation.tool.ToolChoice; +import run.halo.aifoundation.tool.ToolDefinition; + +/** + * Immutable reusable agent that composes one effective request and delegates execution to a + * provider-neutral {@link LanguageModel}. + * + * @param typed per-call options + */ +public final class Agent { + /** Maximum model steps used when the definition does not provide a stop condition. */ + public static final int DEFAULT_MAX_STEPS = 20; + + private final AgentOptions definition; + + private Agent(AgentOptions definition) { + this.definition = snapshot(Objects.requireNonNull(definition, + "definition must not be null")); + if (this.definition.getModel() == null) { + throw new IllegalArgumentException("agent model must not be null"); + } + } + + /** + * Creates a typed agent from a complete immutable definition. + */ + public static Agent create(AgentOptions definition) { + return new Agent<>(definition); + } + + /** + * Creates a no-options agent with the default bounded step policy. + */ + public static Agent create(LanguageModel model, String instructions) { + return create(AgentOptions.forModel(model) + .instructions(instructions) + .build()); + } + + /** + * Returns a defensive snapshot of this agent's definition. + */ + public AgentOptions options() { + return snapshot(definition); + } + + /** + * Generates a normalized terminal result through the configured model. + */ + public Mono generate(AgentCall call) { + return prepare(call).flatMap(prepared -> + prepared.getModel().generateText(prepared.getRequest())); + } + + /** + * Streams through the configured model while sharing one preparation and provider execution + * across all result projections. + */ + public StreamTextResult stream(AgentCall call) { + return LanguageModelMiddlewares.defer(prepare(call) + .map(prepared -> prepared.getModel().streamText(prepared.getRequest()))); + } + + private Mono prepare(AgentCall source) { + return Mono.defer(() -> { + var call = snapshot(Objects.requireNonNull(source, "call must not be null")); + validateInput(call); + validateOptions(call.getOptions()); + checkCancellation(call); + var builder = requestBuilder(call); + var context = new AgentCallPrepareContext<>(call, call.getOptions(), + definition.getModel(), builder); + Mono prepared; + try { + prepared = definition.getPrepareCall() == null + ? Mono.just(context.prepared()) + : definition.getPrepareCall().prepare(context); + } catch (Throwable error) { + return Mono.error(preparationFailure(error)); + } + if (prepared == null) { + return Mono.error(new AgentCallException(AgentCallPhase.PREPARATION, + "Agent call preparation returned null")); + } + return prepared + .switchIfEmpty(Mono.error(new AgentCallException(AgentCallPhase.PREPARATION, + "Agent call preparation returned no prepared call"))) + .map(this::validatedPreparedCall) + .onErrorMap(this::preparationFailure); + }); + } + + private AgentCallException preparationFailure(Throwable error) { + if (error instanceof AgentCallException agentError) { + return agentError; + } + if (error instanceof AiGenerationCancelledException cancelled) { + throw cancelled; + } + return new AgentCallException(AgentCallPhase.PREPARATION, + "Agent call preparation failed: " + safeMessage(error), error); + } + + private PreparedAgentCall validatedPreparedCall(PreparedAgentCall prepared) { + if (prepared == null || prepared.getModel() == null || prepared.getRequest() == null) { + throw new AgentCallException(AgentCallPhase.PREPARATION, + "Agent call preparation produced incomplete state"); + } + var callToken = prepared.getRequest().getCancellationToken(); + if (callToken != null) { + callToken.throwIfCancellationRequested(); + } + return new PreparedAgentCall(prepared.getModel(), copyRequest(prepared.getRequest())); + } + + private void validateInput(AgentCall call) { + var hasPrompt = call.getPrompt() != null && !call.getPrompt().isBlank(); + var hasMessages = call.getMessages() != null && !call.getMessages().isEmpty(); + if (hasPrompt == hasMessages) { + throw new AgentCallException(AgentCallPhase.VALIDATION, + "Agent call must contain either a prompt or messages, but not both"); + } + } + + private void validateOptions(O options) { + if (definition.getCallValidator() == null) { + return; + } + try { + definition.getCallValidator().validate(options); + } catch (AgentCallException error) { + throw error; + } catch (Throwable error) { + throw new AgentCallException(AgentCallPhase.VALIDATION, + "Agent call options are invalid: " + safeMessage(error), error); + } + } + + private void checkCancellation(AgentCall call) { + if (call.getCancellationToken() != null) { + call.getCancellationToken().throwIfCancellationRequested(); + } + } + + private GenerateTextRequest.GenerateTextRequestBuilder requestBuilder(AgentCall call) { + var lifecycle = new ArrayList<>(definition.getLifecycle()); + lifecycle.addAll(call.getLifecycle()); + var middleware = new ArrayList<>(definition.getMiddleware()); + middleware.addAll(call.getMiddleware()); + var builder = GenerateTextRequest.builder() + .system(definition.getInstructions()) + .maxOutputTokens(definition.getMaxOutputTokens()) + .temperature(definition.getTemperature()) + .topP(definition.getTopP()) + .topK(definition.getTopK()) + .minP(definition.getMinP()) + .presencePenalty(definition.getPresencePenalty()) + .frequencyPenalty(definition.getFrequencyPenalty()) + .repetitionPenalty(definition.getRepetitionPenalty()) + .logprobs(definition.getLogprobs()) + .topLogprobs(definition.getTopLogprobs()) + .parallelToolCalls(definition.getParallelToolCalls()) + .stopSequences(definition.getStopSequences()) + .seed(definition.getSeed()) + .maxRetries(definition.getMaxRetries()) + .reasoning(copy(definition.getReasoning())) + .headers(merge(definition.getHeaders(), call.getHeaders())) + .metadata(merge(definition.getMetadata(), call.getMetadata())) + .context(merge(definition.getContext(), call.getContext())) + .output(copy(definition.getOutput())) + .tools(copyTools(definition.getTools())) + .toolChoice(copy(definition.getToolChoice())) + .stopWhen(definition.getStopWhen() != null + ? definition.getStopWhen() + : StopCondition.stepCountIs(DEFAULT_MAX_STEPS)) + .prepareStep(withActiveTools(definition.getActiveTools(), + definition.getPrepareStep())) + .lifecycle(composite(lifecycle)) + .toolCallRepair(definition.getToolCallRepair()) + .cancellationToken(call.getCancellationToken()) + .timeouts(merge(definition.getTimeouts(), call.getTimeouts())); + if (!middleware.isEmpty()) { + builder.middleware(middleware.toArray(LanguageModelMiddleware[]::new)); + } + if (call.getPrompt() != null && !call.getPrompt().isBlank()) { + builder.prompt(call.getPrompt()); + } else { + builder.messages(List.copyOf(call.getMessages())); + } + return builder; + } + + private PrepareStepCallback withActiveTools(List activeTools, + PrepareStepCallback delegate) { + if (activeTools == null && delegate == null) { + return null; + } + var initialActiveTools = activeTools == null ? null : List.copyOf(activeTools); + return context -> { + var prepared = delegate != null ? delegate.prepare(context) : null; + if (prepared != null && prepared.getActiveTools() != null) { + return prepared; + } + if (initialActiveTools == null) { + return prepared; + } + return copy(prepared, initialActiveTools); + }; + } + + private PreparedStep copy(PreparedStep source, List activeTools) { + if (source == null) { + return PreparedStep.builder().activeTools(activeTools).build(); + } + return PreparedStep.builder() + .messages(source.getMessages() == null ? null : List.copyOf(source.getMessages())) + .toolChoice(copy(source.getToolChoice())) + .activeTools(activeTools) + .maxOutputTokens(source.getMaxOutputTokens()) + .temperature(source.getTemperature()) + .topP(source.getTopP()) + .topK(source.getTopK()) + .minP(source.getMinP()) + .presencePenalty(source.getPresencePenalty()) + .frequencyPenalty(source.getFrequencyPenalty()) + .repetitionPenalty(source.getRepetitionPenalty()) + .logprobs(source.getLogprobs()) + .topLogprobs(source.getTopLogprobs()) + .parallelToolCalls(source.getParallelToolCalls()) + .stopSequences(source.getStopSequences() == null + ? null : List.copyOf(source.getStopSequences())) + .seed(source.getSeed()) + .maxRetries(source.getMaxRetries()) + .stopWhen(source.getStopWhen()) + .build(); + } + + private GenerationLifecycle composite(List lifecycle) { + if (lifecycle == null || lifecycle.isEmpty()) { + return null; + } + var entries = List.copyOf(lifecycle); + return new GenerationLifecycle() { + @Override + public Mono onStart(GenerationStartEvent event) { + return invoke(entries, value -> value.onStart(event)); + } + + @Override + public Mono onStepStart(GenerationStepStartEvent event) { + return invoke(entries, value -> value.onStepStart(event)); + } + + @Override + public Mono onToolCallStart(GenerationToolCallStartEvent event) { + return invoke(entries, value -> value.onToolCallStart(event)); + } + + @Override + public Mono onToolCallFinish(GenerationToolCallFinishEvent event) { + return invoke(entries, value -> value.onToolCallFinish(event)); + } + + @Override + public Mono onToolApprovalRequest(GenerationToolApprovalRequestEvent event) { + return invoke(entries, value -> value.onToolApprovalRequest(event)); + } + + @Override + public Mono onStepFinish(GenerationStepFinishEvent event) { + return invoke(entries, value -> value.onStepFinish(event)); + } + + @Override + public Mono onFinish(GenerationFinishEvent event) { + return invoke(entries, value -> value.onFinish(event)); + } + + @Override + public Mono onError(GenerationErrorEvent event) { + return invoke(entries, value -> value.onError(event)); + } + }; + } + + private Mono invoke(List lifecycle, + Function> callback) { + return Flux.fromIterable(lifecycle) + .concatMap(value -> Mono.defer(() -> callback.apply(value))) + .then(); + } + + private GenerateTextRequest copyRequest(GenerateTextRequest source) { + var builder = GenerateTextRequest.builder() + .system(source.getSystem()) + .prompt(source.getPrompt()) + .messages(source.getMessages() == null ? null : List.copyOf(source.getMessages())) + .maxOutputTokens(source.getMaxOutputTokens()) + .temperature(source.getTemperature()) + .topP(source.getTopP()) + .topK(source.getTopK()) + .minP(source.getMinP()) + .presencePenalty(source.getPresencePenalty()) + .frequencyPenalty(source.getFrequencyPenalty()) + .repetitionPenalty(source.getRepetitionPenalty()) + .logprobs(source.getLogprobs()) + .topLogprobs(source.getTopLogprobs()) + .parallelToolCalls(source.getParallelToolCalls()) + .stopSequences(source.getStopSequences() == null + ? null : List.copyOf(source.getStopSequences())) + .seed(source.getSeed()) + .maxRetries(source.getMaxRetries()) + .reasoning(copy(source.getReasoning())) + .headers(immutableMap(source.getHeaders())) + .metadata(immutableMap(source.getMetadata())) + .context(immutableMap(source.getContext())) + .output(copy(source.getOutput())) + .tools(copyTools(source.getTools())) + .toolChoice(copy(source.getToolChoice())) + .stopWhen(source.getStopWhen()) + .prepareStep(source.getPrepareStep()) + .lifecycle(source.getLifecycle()) + .toolCallRepair(source.getToolCallRepair()) + .cancellationToken(source.getCancellationToken()) + .timeouts(source.getTimeouts()); + if (source.getMiddleware() != null && !source.getMiddleware().isEmpty()) { + builder.middleware(source.getMiddleware().toArray(LanguageModelMiddleware[]::new)); + } + return builder.build(); + } + + private static AgentOptions snapshot(AgentOptions source) { + return source.toBuilder() + .tools(copyTools(source.getTools())) + .activeTools(copyNullableList(source.getActiveTools())) + .toolChoice(copy(source.getToolChoice())) + .output(copy(source.getOutput())) + .reasoning(copy(source.getReasoning())) + .stopSequences(copyList(source.getStopSequences())) + .headers(immutableMap(source.getHeaders())) + .metadata(immutableMap(source.getMetadata())) + .context(immutableMap(source.getContext())) + .middleware(copyList(source.getMiddleware())) + .lifecycle(copyList(source.getLifecycle())) + .build(); + } + + private static AgentCall snapshot(AgentCall source) { + return AgentCall.builder() + .prompt(source.getPrompt()) + .messages(source.getMessages()) + .options(source.getOptions()) + .metadata(source.getMetadata()) + .context(source.getContext()) + .headers(source.getHeaders()) + .cancellationToken(source.getCancellationToken()) + .timeouts(source.getTimeouts()) + .lifecycle(source.getLifecycle()) + .middleware(source.getMiddleware()) + .build(); + } + + private static List copyTools(List source) { + if (source == null || source.isEmpty()) { + return List.of(); + } + return source.stream().map(Agent::copy).toList(); + } + + private static ToolDefinition copy(ToolDefinition source) { + if (source == null) { + return null; + } + return ToolDefinition.builder() + .name(source.getName()) + .description(source.getDescription()) + .inputSchema(deepMap(source.getInputSchema())) + .outputSchema(deepMap(source.getOutputSchema())) + .inputExamples(source.getInputExamples() == null ? null + : source.getInputExamples().stream().map(Agent::deepMap).toList()) + .strict(source.getStrict()) + .approvalPolicy(source.getApprovalPolicy()) + .onInputStart(source.getOnInputStart()) + .onInputDelta(source.getOnInputDelta()) + .onInputAvailable(source.getOnInputAvailable()) + .executor(source.getExecutor()) + .build(); + } + + private static ToolChoice copy(ToolChoice source) { + return source == null ? null : ToolChoice.builder() + .type(source.getType()) + .toolName(source.getToolName()) + .build(); + } + + private static ReasoningOptions copy(ReasoningOptions source) { + return source == null ? null : ReasoningOptions.builder() + .mode(source.getMode()) + .effort(source.getEffort()) + .build(); + } + + private static OutputSpec copy(OutputSpec source) { + return source == null ? null : OutputSpec.builder() + .type(source.getType()) + .name(source.getName()) + .description(source.getDescription()) + .schema(deepMap(source.getSchema())) + .elementSchema(deepMap(source.getElementSchema())) + .choices(copyList(source.getChoices())) + .strict(source.getStrict()) + .outputClass(source.getOutputClass()) + .elementClass(source.getElementClass()) + .build(); + } + + private static GenerationTimeouts merge(GenerationTimeouts base, GenerationTimeouts call) { + if (call == null) { + return base; + } + if (base == null) { + return call; + } + return GenerationTimeouts.builder() + .totalTimeout(call.getTotalTimeout() != null + ? call.getTotalTimeout() : base.getTotalTimeout()) + .stepTimeout(call.getStepTimeout() != null + ? call.getStepTimeout() : base.getStepTimeout()) + .toolTimeout(call.getToolTimeout() != null + ? call.getToolTimeout() : base.getToolTimeout()) + .build(); + } + + private static Map merge(Map base, Map call) { + var merged = new LinkedHashMap(); + if (base != null) { + merged.putAll(base); + } + if (call != null) { + merged.putAll(call); + } + return immutableMap(merged); + } + + private static List copyList(List source) { + return source == null || source.isEmpty() ? List.of() : List.copyOf(source); + } + + private static List copyNullableList(List source) { + return source == null ? null : List.copyOf(source); + } + + private static Map immutableMap(Map source) { + if (source == null || source.isEmpty()) { + return Map.of(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(source)); + } + + @SuppressWarnings("unchecked") + private static Map deepMap(Map source) { + if (source == null || source.isEmpty()) { + return source == null ? null : Map.of(); + } + var copy = new LinkedHashMap(); + source.forEach((key, value) -> copy.put(key, deepValue(value))); + return Collections.unmodifiableMap(copy); + } + + private static Object deepValue(Object value) { + if (value instanceof Map map) { + var copy = new LinkedHashMap(); + map.forEach((key, nested) -> copy.put(key, deepValue(nested))); + return Collections.unmodifiableMap(copy); + } + if (value instanceof List list) { + return Collections.unmodifiableList(list.stream().map(Agent::deepValue).toList()); + } + return value; + } + + private String safeMessage(Throwable error) { + if (error == null || error.getMessage() == null || error.getMessage().isBlank()) { + return error == null ? "unknown error" : error.getClass().getSimpleName(); + } + return error.getMessage(); + } +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/AgentCall.java b/api/src/main/java/run/halo/aifoundation/agent/AgentCall.java new file mode 100644 index 00000000..ebb46900 --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/AgentCall.java @@ -0,0 +1,91 @@ +package run.halo.aifoundation.agent; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.Value; +import run.halo.aifoundation.chat.GenerationTimeouts; +import run.halo.aifoundation.chat.middleware.LanguageModelMiddleware; +import run.halo.aifoundation.control.CancellationToken; +import run.halo.aifoundation.lifecycle.GenerationLifecycle; +import run.halo.aifoundation.message.ModelMessage; + +/** + * Immutable typed input and operational controls for one agent invocation. + * + *

Use either a prompt or model messages, but not both. Agent-owned policy such as tools, + * instructions, output, and stop conditions is intentionally not exposed here. + * + * @param call options type + */ +@Value +public class AgentCall { + String prompt; + List messages; + O options; + Map metadata; + Map context; + Map headers; + CancellationToken cancellationToken; + GenerationTimeouts timeouts; + List lifecycle; + List middleware; + + @Builder + private AgentCall(String prompt, List messages, O options, + Map metadata, Map context, Map headers, + CancellationToken cancellationToken, GenerationTimeouts timeouts, + List lifecycle, List middleware) { + this.prompt = prompt; + this.messages = messages == null ? List.of() : List.copyOf(messages); + this.options = options; + this.metadata = immutableMap(metadata); + this.context = immutableMap(context); + this.headers = immutableStringMap(headers); + this.cancellationToken = cancellationToken; + this.timeouts = timeouts; + this.lifecycle = lifecycle == null ? List.of() : List.copyOf(lifecycle); + this.middleware = middleware == null ? List.of() : List.copyOf(middleware); + } + + /** + * Creates a no-options prompt call. + */ + public static AgentCall prompt(String prompt) { + return AgentCall.builder().prompt(prompt).build(); + } + + /** + * Creates a typed prompt call. + */ + public static AgentCall prompt(String prompt, O options) { + return AgentCall.builder().prompt(prompt).options(options).build(); + } + + /** + * Creates a no-options message call. + */ + public static AgentCall messages(List messages) { + return AgentCall.builder().messages(messages).build(); + } + + /** + * Creates a typed message call. + */ + public static AgentCall messages(List messages, O options) { + return AgentCall.builder().messages(messages).options(options).build(); + } + + private static Map immutableMap(Map source) { + if (source == null || source.isEmpty()) { + return Map.of(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(source)); + } + + private static Map immutableStringMap(Map source) { + return immutableMap(source); + } +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/AgentCallException.java b/api/src/main/java/run/halo/aifoundation/agent/AgentCallException.java new file mode 100644 index 00000000..7ccd4d4a --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/AgentCallException.java @@ -0,0 +1,24 @@ +package run.halo.aifoundation.agent; + +import run.halo.aifoundation.exception.AiFoundationException; + +/** + * Stable pre-provider failure raised while validating or preparing an agent call. + */ +public class AgentCallException extends AiFoundationException { + private final AgentCallPhase phase; + + public AgentCallException(AgentCallPhase phase, String message) { + super(message); + this.phase = phase; + } + + public AgentCallException(AgentCallPhase phase, String message, Throwable cause) { + super(message, cause); + this.phase = phase; + } + + public AgentCallPhase getPhase() { + return phase; + } +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/AgentCallPhase.java b/api/src/main/java/run/halo/aifoundation/agent/AgentCallPhase.java new file mode 100644 index 00000000..23c24aec --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/AgentCallPhase.java @@ -0,0 +1,11 @@ +package run.halo.aifoundation.agent; + +/** + * Phase in which an agent call failed before model execution. + */ +public enum AgentCallPhase { + /** Typed call options or call input were invalid. */ + VALIDATION, + /** Asynchronous call preparation failed or returned invalid state. */ + PREPARATION +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/AgentCallPrepare.java b/api/src/main/java/run/halo/aifoundation/agent/AgentCallPrepare.java new file mode 100644 index 00000000..8cbd4a36 --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/AgentCallPrepare.java @@ -0,0 +1,17 @@ +package run.halo.aifoundation.agent; + +import reactor.core.publisher.Mono; + +/** + * Asynchronously prepares one effective model and request for an agent call. + * + * @param call options type + */ +@FunctionalInterface +public interface AgentCallPrepare { + + /** + * Prepares the current call. The callback runs exactly once per subscribed agent call. + */ + Mono prepare(AgentCallPrepareContext context); +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/AgentCallPrepareContext.java b/api/src/main/java/run/halo/aifoundation/agent/AgentCallPrepareContext.java new file mode 100644 index 00000000..fa6dae20 --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/AgentCallPrepareContext.java @@ -0,0 +1,42 @@ +package run.halo.aifoundation.agent; + +import java.util.Objects; +import lombok.Value; +import run.halo.aifoundation.chat.GenerateTextRequest; +import run.halo.aifoundation.chat.LanguageModel; + +/** + * Request-scoped context for one-time asynchronous agent call preparation. + * + * @param call options type + */ +@Value +public class AgentCallPrepareContext { + AgentCall call; + O options; + LanguageModel baseModel; + GenerateTextRequest.GenerateTextRequestBuilder requestBuilder; + + public AgentCallPrepareContext(AgentCall call, O options, LanguageModel baseModel, + GenerateTextRequest.GenerateTextRequestBuilder requestBuilder) { + this.call = Objects.requireNonNull(call, "call must not be null"); + this.options = options; + this.baseModel = Objects.requireNonNull(baseModel, "baseModel must not be null"); + this.requestBuilder = Objects.requireNonNull(requestBuilder, + "requestBuilder must not be null"); + } + + /** + * Builds a prepared call using the agent's base model. + */ + public PreparedAgentCall prepared() { + return prepared(baseModel); + } + + /** + * Builds a prepared call using a model selected for this invocation only. + */ + public PreparedAgentCall prepared(LanguageModel model) { + return new PreparedAgentCall(model, requestBuilder.build()); + } +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/AgentCallValidator.java b/api/src/main/java/run/halo/aifoundation/agent/AgentCallValidator.java new file mode 100644 index 00000000..8155083d --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/AgentCallValidator.java @@ -0,0 +1,15 @@ +package run.halo.aifoundation.agent; + +/** + * Validates typed options before an agent call is prepared. + * + * @param call options type + */ +@FunctionalInterface +public interface AgentCallValidator { + + /** + * Validates the supplied options. Throw an exception to reject the call. + */ + void validate(O options); +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/AgentOptions.java b/api/src/main/java/run/halo/aifoundation/agent/AgentOptions.java new file mode 100644 index 00000000..7ef43c5a --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/AgentOptions.java @@ -0,0 +1,135 @@ +package run.halo.aifoundation.agent; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.Builder; +import lombok.Value; +import run.halo.aifoundation.chat.GenerationTimeouts; +import run.halo.aifoundation.chat.LanguageModel; +import run.halo.aifoundation.chat.PrepareStepCallback; +import run.halo.aifoundation.chat.ReasoningOptions; +import run.halo.aifoundation.chat.StopCondition; +import run.halo.aifoundation.chat.middleware.LanguageModelMiddleware; +import run.halo.aifoundation.lifecycle.GenerationLifecycle; +import run.halo.aifoundation.schema.OutputSpec; +import run.halo.aifoundation.tool.ToolCallRepairCallback; +import run.halo.aifoundation.tool.ToolChoice; +import run.halo.aifoundation.tool.ToolDefinition; + +/** + * Immutable definition options for a reusable agent. + * + * @param typed per-call options + */ +@Value +public class AgentOptions { + String id; + LanguageModel model; + String instructions; + List tools; + List activeTools; + ToolChoice toolChoice; + OutputSpec output; + StopCondition stopWhen; + PrepareStepCallback prepareStep; + ToolCallRepairCallback toolCallRepair; + ReasoningOptions reasoning; + Integer maxOutputTokens; + Double temperature; + Double topP; + Integer topK; + Double minP; + Double presencePenalty; + Double frequencyPenalty; + Double repetitionPenalty; + Boolean logprobs; + Integer topLogprobs; + Boolean parallelToolCalls; + List stopSequences; + Integer seed; + Integer maxRetries; + Map headers; + Map metadata; + Map context; + List middleware; + List lifecycle; + GenerationTimeouts timeouts; + AgentCallValidator callValidator; + AgentCallPrepare prepareCall; + + @Builder(toBuilder = true) + private AgentOptions(String id, LanguageModel model, String instructions, + List tools, List activeTools, ToolChoice toolChoice, + OutputSpec output, StopCondition stopWhen, PrepareStepCallback prepareStep, + ToolCallRepairCallback toolCallRepair, ReasoningOptions reasoning, + Integer maxOutputTokens, Double temperature, Double topP, Integer topK, Double minP, + Double presencePenalty, Double frequencyPenalty, Double repetitionPenalty, + Boolean logprobs, Integer topLogprobs, Boolean parallelToolCalls, + List stopSequences, Integer seed, Integer maxRetries, Map headers, + Map metadata, Map context, + List middleware, List lifecycle, + GenerationTimeouts timeouts, AgentCallValidator callValidator, + AgentCallPrepare prepareCall) { + this.id = id; + this.model = model; + this.instructions = instructions; + this.tools = tools == null ? List.of() : List.copyOf(tools); + // Keep null distinct from an explicit empty list: null means all request tools are + // available, while an empty list intentionally disables every tool. + this.activeTools = activeTools == null ? null : List.copyOf(activeTools); + this.toolChoice = toolChoice; + this.output = output; + this.stopWhen = stopWhen; + this.prepareStep = prepareStep; + this.toolCallRepair = toolCallRepair; + this.reasoning = reasoning; + this.maxOutputTokens = maxOutputTokens; + this.temperature = temperature; + this.topP = topP; + this.topK = topK; + this.minP = minP; + this.presencePenalty = presencePenalty; + this.frequencyPenalty = frequencyPenalty; + this.repetitionPenalty = repetitionPenalty; + this.logprobs = logprobs; + this.topLogprobs = topLogprobs; + this.parallelToolCalls = parallelToolCalls; + this.stopSequences = stopSequences == null ? List.of() : List.copyOf(stopSequences); + this.seed = seed; + this.maxRetries = maxRetries; + this.headers = immutableMap(headers); + this.metadata = immutableMap(metadata); + this.context = immutableMap(context); + this.middleware = middleware == null ? List.of() : List.copyOf(middleware); + this.lifecycle = lifecycle == null ? List.of() : List.copyOf(lifecycle); + this.timeouts = timeouts; + this.callValidator = callValidator; + this.prepareCall = prepareCall; + } + + /** + * Creates a definition builder with the required model already selected. + */ + public static AgentOptionsBuilder forModel(LanguageModel model) { + return AgentOptions.builder().model(model); + } + + /** + * Creates a typed definition builder with the required model already selected. + */ + public static AgentOptionsBuilder forModel(LanguageModel model, Class optionsType) { + if (optionsType == null) { + throw new IllegalArgumentException("optionsType must not be null"); + } + return AgentOptions.builder().model(model); + } + + private static Map immutableMap(Map source) { + if (source == null || source.isEmpty()) { + return Map.of(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(source)); + } +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/PreparedAgentCall.java b/api/src/main/java/run/halo/aifoundation/agent/PreparedAgentCall.java new file mode 100644 index 00000000..394d4b1b --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/PreparedAgentCall.java @@ -0,0 +1,20 @@ +package run.halo.aifoundation.agent; + +import java.util.Objects; +import lombok.Value; +import run.halo.aifoundation.chat.GenerateTextRequest; +import run.halo.aifoundation.chat.LanguageModel; + +/** + * Effective model and request produced by one-time agent call preparation. + */ +@Value +public class PreparedAgentCall { + LanguageModel model; + GenerateTextRequest request; + + public PreparedAgentCall(LanguageModel model, GenerateTextRequest request) { + this.model = Objects.requireNonNull(model, "model must not be null"); + this.request = Objects.requireNonNull(request, "request must not be null"); + } +} diff --git a/api/src/main/java/run/halo/aifoundation/agent/package-info.java b/api/src/main/java/run/halo/aifoundation/agent/package-info.java new file mode 100644 index 00000000..ab3347a3 --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/agent/package-info.java @@ -0,0 +1,4 @@ +/** + * Provider-neutral reusable agent definitions and invocation contracts. + */ +package run.halo.aifoundation.agent; diff --git a/api/src/main/java/run/halo/aifoundation/tool/ToolCallFailureKind.java b/api/src/main/java/run/halo/aifoundation/tool/ToolCallFailureKind.java new file mode 100644 index 00000000..a01d65de --- /dev/null +++ b/api/src/main/java/run/halo/aifoundation/tool/ToolCallFailureKind.java @@ -0,0 +1,11 @@ +package run.halo.aifoundation.tool; + +/** + * Provider-neutral reason a model-produced tool call entered recovery. + */ +public enum ToolCallFailureKind { + /** A known tool received malformed or schema-invalid input. */ + INVALID_INPUT, + /** The model requested a tool absent from the current available tool set. */ + UNKNOWN_TOOL +} diff --git a/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairCallback.java b/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairCallback.java index e9c36e87..5aeddf95 100644 --- a/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairCallback.java +++ b/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairCallback.java @@ -3,12 +3,10 @@ import reactor.core.publisher.Mono; /** - * Request-scoped callback that can repair invalid tool call input before tool processing. + * Request-scoped callback that can recover invalid or unknown tool calls before tool processing. * - *

The callback is invoked at most once for a known internal or external tool when model output - * fails input schema validation. Repaired input is validated again before availability, approval, - * external handoff, or execution. Returning {@link ToolCallRepairResult#unrepaired()} keeps the - * original validation failure. + *

Repaired calls are fully validated again before availability, approval, external handoff, or + * execution. Returning {@link ToolCallRepairResult#unrepaired()} keeps the original safe failure. */ @FunctionalInterface public interface ToolCallRepairCallback { diff --git a/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairContext.java b/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairContext.java index 61c232b5..1e4c3ac4 100644 --- a/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairContext.java +++ b/api/src/main/java/run/halo/aifoundation/tool/ToolCallRepairContext.java @@ -1,8 +1,9 @@ package run.halo.aifoundation.tool; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; @@ -12,10 +13,12 @@ * Provider-neutral context passed to a tool call repair callback. */ @Data -@Builder @NoArgsConstructor -@AllArgsConstructor public class ToolCallRepairContext { + /** + * Typed reason the tool call entered recovery. + */ + private ToolCallFailureKind failureKind; /** * Original invalid tool call produced by the model. */ @@ -24,6 +27,10 @@ public class ToolCallRepairContext { * Matching request-scoped tool definition. */ private ToolDefinition tool; + /** + * Complete request-scoped tool set currently available to the model. + */ + private List availableTools; /** * Human-readable validation error from the original input validation failure. */ @@ -48,4 +55,28 @@ public class ToolCallRepairContext { * Provider metadata from the tool call and surrounding step. */ private Map providerMetadata; + + @Builder + private ToolCallRepairContext(ToolCallFailureKind failureKind, ToolCall toolCall, + ToolDefinition tool, List availableTools, String validationError, + String validationPath, Integer stepIndex, List messages, + Map requestContext, Map providerMetadata) { + this.failureKind = failureKind; + this.toolCall = toolCall; + this.tool = tool; + this.availableTools = availableTools == null ? List.of() : List.copyOf(availableTools); + this.validationError = validationError; + this.validationPath = validationPath; + this.stepIndex = stepIndex; + this.messages = messages == null ? List.of() : List.copyOf(messages); + this.requestContext = immutableMap(requestContext); + this.providerMetadata = immutableMap(providerMetadata); + } + + private static Map immutableMap(Map source) { + if (source == null || source.isEmpty()) { + return Map.of(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(source)); + } } diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatHandlers.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatHandlers.java index e1e49a80..55c47307 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatHandlers.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatHandlers.java @@ -5,6 +5,7 @@ import java.util.function.Consumer; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; +import run.halo.aifoundation.agent.Agent; import run.halo.aifoundation.chat.GenerateTextRequest; import run.halo.aifoundation.chat.LanguageModel; import run.halo.aifoundation.chat.StreamTextResult; @@ -60,10 +61,7 @@ public static UIMessageChatResult streamText( throw new IllegalArgumentException("UI messages produced no model messages"); } - var modelResult = LanguageModelMiddlewares.defer(finalRequest(baseRequest(options), - conversion, options, validation) - .map(options.model()::streamText) - .cache()); + var modelResult = executionResult(baseRequest(options), conversion, options, validation); var finishSink = Sinks.>one(); var finish = finishSink.asMono().cache(); var stream = UIMessageStreams.createWithOptions(streamOptions -> { @@ -109,6 +107,35 @@ public static UIMessageChatResult streamText(LanguageModel model, }); } + /** + * Streams a chat response from a typed agent and transport request. + * + * @param agent reusable agent + * @param chatRequest framework-neutral chat request + * @param callOptions typed endpoint-owned agent call options + * @param message metadata type + * @param agent call options type + * @return chat stream result + */ + public static UIMessageChatResult streamAgent(Agent agent, + UIMessageChatRequest chatRequest, O callOptions) { + return streamAgent(agent, chatRequest, callOptions, options -> { + }); + } + + /** + * Streams a chat response from a typed agent with transport-level configuration. + */ + public static UIMessageChatResult streamAgent(Agent agent, + UIMessageChatRequest chatRequest, O callOptions, + Consumer> configure) { + Objects.requireNonNull(configure, "configure must not be null"); + return streamText(options -> { + options.agent(agent, callOptions).chatRequest(chatRequest); + configure.accept(options); + }); + } + /** * Streams a chat response from a model and transport request with extra options. * @@ -128,8 +155,9 @@ public static UIMessageChatResult streamText(LanguageModel model, } private static void requireOptions(UIMessageChatOptions options) { - if (options.model() == null) { - throw new IllegalArgumentException("model must not be null"); + if ((options.model() == null) == (options.agent() == null)) { + throw new IllegalArgumentException( + "exactly one of model or agent must be configured"); } if (options.chatRequest() == null && options.messages() == null) { throw new IllegalArgumentException("messages must not be null"); @@ -187,9 +215,34 @@ private static GenerateTextRequest baseRequest(UIMessageChatOptions optio throw new IllegalArgumentException( "UI message chat request customizer must not set cancellationToken"); } + if (options.agent() != null) { + requireAgentTransportRequest(request, options); + } return request; } + private static void requireAgentTransportRequest(GenerateTextRequest request, + UIMessageChatOptions options) { + if (options.prepareCustomized()) { + throw new IllegalArgumentException( + "UI message model request preparation is unavailable for agent execution"); + } + if (request.getSystem() != null || request.getOutput() != null + || request.getTools() != null || request.getToolChoice() != null + || request.getStopWhen() != null || request.getPrepareStep() != null + || request.getToolCallRepair() != null || request.getReasoning() != null + || request.getMaxOutputTokens() != null || request.getTemperature() != null + || request.getTopP() != null || request.getTopK() != null || request.getMinP() != null + || request.getPresencePenalty() != null || request.getFrequencyPenalty() != null + || request.getRepetitionPenalty() != null || request.getLogprobs() != null + || request.getTopLogprobs() != null || request.getParallelToolCalls() != null + || request.getStopSequences() != null || request.getSeed() != null + || request.getMaxRetries() != null) { + throw new IllegalArgumentException( + "UI message transport options must not replace agent policy"); + } + } + private static Consumer> effectiveConversionCustomizer( UIMessageChatOptions options) { return conversion -> { @@ -197,7 +250,7 @@ private static Consumer> effectiveConversionCu if (conversion.reasoningConversion() != UIReasoningConversion.AUTO) { return; } - conversion.reasoningConversion(reasoningHistorySupported(options.model()) + conversion.reasoningConversion(reasoningHistorySupported(executionModel(options)) ? UIReasoningConversion.PRESERVE_PROVIDER_STATE : UIReasoningConversion.DROP); }; @@ -208,6 +261,24 @@ private static boolean reasoningHistorySupported(LanguageModel model) { return capabilities != null && capabilities.reasoningHistorySupported(); } + private static LanguageModel executionModel(UIMessageChatOptions options) { + return options.model() != null + ? options.model() + : options.agent().options().getModel(); + } + + private static StreamTextResult executionResult(GenerateTextRequest source, + UIMessageConversionResult conversion, UIMessageChatOptions options, + List> messages) { + if (options.model() != null) { + return LanguageModelMiddlewares.defer(finalRequest(source, conversion, options, messages) + .map(options.model()::streamText) + .cache()); + } + var middleware = combinedMiddleware(source, options); + return options.agentExecution().stream(conversion.messages(), source, middleware); + } + private static Mono finalRequest(GenerateTextRequest source, UIMessageConversionResult conversion, UIMessageChatOptions options, List> messages) { diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatOptions.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatOptions.java index 1da0864b..f15d22cc 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatOptions.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChatOptions.java @@ -7,6 +7,8 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Supplier; +import run.halo.aifoundation.agent.Agent; +import run.halo.aifoundation.agent.AgentCall; import run.halo.aifoundation.chat.GenerateTextRequest; import run.halo.aifoundation.chat.LanguageModel; import run.halo.aifoundation.chat.middleware.LanguageModelMiddleware; @@ -20,6 +22,8 @@ */ public final class UIMessageChatOptions { private LanguageModel model; + private Agent agent; + private UIMessageAgentExecution agentExecution; private List> messages; private UIMessageChatRequest chatRequest; private UIMessage message; @@ -30,6 +34,7 @@ public final class UIMessageChatOptions { builder -> { }; private UIMessageChatPrepare prepareHandler = context -> Mono.empty(); + private boolean prepareCustomized; private final List middleware = new ArrayList<>(); private Consumer> validationCustomizer = options -> { }; @@ -55,6 +60,42 @@ public UIMessageChatOptions model(LanguageModel model) { return this; } + /** + * Selects a typed agent and its endpoint-owned call options for this chat response. + * + *

Use either {@link #model(LanguageModel)} or this method, not both. The options value is + * passed to {@link AgentCall} after UI messages have been validated and converted. + * + * @param agent reusable agent + * @param callOptions typed call options derived by the endpoint + * @param agent call options type + * @return this options object + */ + public UIMessageChatOptions agent(Agent agent, O callOptions) { + this.agent = Objects.requireNonNull(agent, "agent must not be null"); + this.agentExecution = (messages, source, middleware) -> agent.stream( + AgentCall.builder() + .messages(messages) + .options(callOptions) + .metadata(source.getMetadata()) + .context(source.getContext()) + .headers(source.getHeaders()) + .cancellationToken(cancellationToken) + .timeouts(source.getTimeouts()) + .lifecycle(source.getLifecycle() == null + ? List.of() : List.of(source.getLifecycle())) + .middleware(middleware) + .build()); + return this; + } + + /** + * Selects a no-options agent for this chat response. + */ + public UIMessageChatOptions agent(Agent agent) { + return agent(agent, null); + } + /** * Sets already-normalized persisted UI messages. * @@ -151,6 +192,7 @@ public UIMessageChatOptions request( */ public UIMessageChatOptions prepare(UIMessageChatPrepare prepare) { this.prepareHandler = Objects.requireNonNull(prepare, "prepare must not be null"); + this.prepareCustomized = true; return this; } @@ -258,6 +300,14 @@ LanguageModel model() { return model; } + Agent agent() { + return agent; + } + + UIMessageAgentExecution agentExecution() { + return agentExecution; + } + List> messages() { return messages; } @@ -290,6 +340,10 @@ UIMessageChatPrepare prepareHandler() { return prepareHandler; } + boolean prepareCustomized() { + return prepareCustomized; + } + List middleware() { return List.copyOf(middleware); } @@ -321,4 +375,12 @@ CancellationToken cancellationToken() { boolean terminateOnError() { return terminateOnError; } + + @FunctionalInterface + interface UIMessageAgentExecution { + run.halo.aifoundation.chat.StreamTextResult stream( + List messages, + GenerateTextRequest source, + List middleware); + } } diff --git a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java index 5ea664b1..02966739 100644 --- a/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java +++ b/api/src/main/java/run/halo/aifoundation/ui/UIMessageChunkReducer.java @@ -201,8 +201,10 @@ private boolean replaceTool(ToolChunk tool) { } private boolean startToolInput(ToolInputStartChunk tool) { - replaceByIdentity(UIMessageParts.tool(tool.toolCallId(), tool.toolName(), - ToolPartState.INPUT_STREAMING, null, "", null, null, null, Map.of())); + replace(part -> part instanceof ToolPart value + && tool.toolCallId().equals(value.toolCallId()), + UIMessageParts.tool(tool.toolCallId(), tool.toolName(), + ToolPartState.INPUT_STREAMING, null, "", null, null, null, Map.of())); return true; } @@ -247,9 +249,11 @@ private boolean replaceTool(String toolCallId, String toolName, ToolPartState st } } - replaceByIdentity(UIMessageParts.tool(toolCallId, toolName, state, - resolvedInput, inputText, resolvedOutput, resolvedErrorText, resolvedApproval, - resolvedMetadata)); + replace(part -> part instanceof ToolPart value + && toolCallId.equals(value.toolCallId()), + UIMessageParts.tool(toolCallId, toolName, state, + resolvedInput, inputText, resolvedOutput, resolvedErrorText, resolvedApproval, + resolvedMetadata)); return true; } diff --git a/app/src/main/java/run/halo/aifoundation/endpoint/ModelConsoleEndpoint.java b/app/src/main/java/run/halo/aifoundation/endpoint/ModelConsoleEndpoint.java index 9e9b8276..8a88b5c8 100644 --- a/app/src/main/java/run/halo/aifoundation/endpoint/ModelConsoleEndpoint.java +++ b/app/src/main/java/run/halo/aifoundation/endpoint/ModelConsoleEndpoint.java @@ -6,12 +6,14 @@ import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import lombok.AllArgsConstructor; import lombok.Builder; @@ -32,6 +34,8 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.aifoundation.AiModelService; +import run.halo.aifoundation.agent.Agent; +import run.halo.aifoundation.agent.AgentOptions; import run.halo.aifoundation.embedding.EmbeddingRequest; import run.halo.aifoundation.embedding.EmbeddingResponseMetadata; import run.halo.aifoundation.embedding.EmbeddingUsage; @@ -39,11 +43,14 @@ import run.halo.aifoundation.embedding.EmbeddingWarning; import run.halo.aifoundation.chat.GenerateTextRequest; import run.halo.aifoundation.chat.GenerationResponseMetadata; +import run.halo.aifoundation.chat.PreparedStep; import run.halo.aifoundation.image.GenerateImageRequest; import run.halo.aifoundation.image.GenerateImageResult; import run.halo.aifoundation.image.ImageGenerationWarning; import run.halo.aifoundation.image.ImageResponseFormat; import run.halo.aifoundation.image.ImageUsage; +import run.halo.aifoundation.lifecycle.GenerationLifecycle; +import run.halo.aifoundation.lifecycle.GenerationStepFinishEvent; import run.halo.aifoundation.media.DataContent; import run.halo.aifoundation.media.GeneratedFile; import run.halo.aifoundation.part.PartType; @@ -68,6 +75,8 @@ import run.halo.aifoundation.source.RetrievedContext; import run.halo.aifoundation.source.RetrievedSource; import run.halo.aifoundation.tool.ToolCall; +import run.halo.aifoundation.tool.ToolCallFailureKind; +import run.halo.aifoundation.tool.ToolCallRepairCallback; import run.halo.aifoundation.tool.ToolCallRepairResult; import run.halo.aifoundation.tool.ToolDefinition; import run.halo.aifoundation.ui.InvalidUIMessageException; @@ -105,6 +114,8 @@ public class ModelConsoleEndpoint implements CustomEndpoint { private static final String CONSOLE_TOOL_INPUT_STREAM_TEST_TOOL_NAME = "halo_tool_input_stream_test"; private static final String CONSOLE_REPAIR_TEST_TOOL_NAME = "halo_repair_test_info"; + private static final String CONSOLE_LEGACY_REPAIR_TEST_TOOL_NAME = + "halo_legacy_repair_test_info"; private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); private final ReactiveExtensionClient client; @@ -213,6 +224,13 @@ public RouterFunction endpoint() { + "that are executed by the workbench frontend.") .implementation(Boolean.class) .required(false)) + .parameter(parameterBuilder() + .name("enableToolInputStreamTest") + .in(ParameterIn.QUERY) + .description("Whether to inject the console-only lifecycle-aware tool for " + + "streamed tool-input diagnostics.") + .implementation(Boolean.class) + .required(false)) .requestBody(requestBodyBuilder() .required(true) .implementation(TestUiMessageChatRequest.class)) @@ -390,23 +408,9 @@ private Mono testUiMessageChatStream(ServerRequest request) { .flatMap(body -> validateTestUiMessageChatRequest(body).then(Mono.defer(() -> { var cancellation = UIMessageCancellations.create(); return aiModelService.languageModel(modelName) - .map(languageModel -> UIMessageChatHandlers.>streamText( - options -> options - .model(languageModel) - .chatRequest(toUiMessageChatRequest(body)) - .metadataSupplier(() -> new LinkedHashMap<>()) - .serializer(ModelConsoleEndpoint::writeJson) - .request(builder -> applyConsoleGenerationOptions(builder, body, - ConsoleTestToolOptions.from(request))) - .cancellationToken(cancellation.token()) - .onError(ModelConsoleEndpoint::safeMessage) - .onFinish(finish -> log.debug( - "UI message chat test finished: modelName={}, messages={}, " - + "aborted={}, errorText={}", - modelName, finish.messages().size(), finish.terminal().aborted(), - finish.terminal().errorText() - )) - )) + .map(languageModel -> body.agentEnabled() + ? consoleAgentChat(languageModel, body, cancellation, modelName) + : directModelChat(languageModel, body, request, cancellation, modelName)) .flatMap(chat -> uiMessageStreamResponse(chat.response(), cancellation)); }))) .onErrorResume(error -> { @@ -422,6 +426,104 @@ private Mono testUiMessageChatStream(ServerRequest request) { }); } + private run.halo.aifoundation.ui.UIMessageChatResult> directModelChat( + run.halo.aifoundation.chat.LanguageModel languageModel, + TestUiMessageChatRequest body, ServerRequest request, + UIMessageCancellation cancellation, String modelName) { + return UIMessageChatHandlers.streamText(options -> options + .model(languageModel) + .chatRequest(toUiMessageChatRequest(body)) + .metadataSupplier(LinkedHashMap::new) + .serializer(ModelConsoleEndpoint::writeJson) + .request(builder -> applyConsoleGenerationOptions(builder, body, + ConsoleTestToolOptions.from(request))) + .cancellationToken(cancellation.token()) + .onError(ModelConsoleEndpoint::safeMessage) + .onFinish(finish -> logUiMessageFinish(modelName, finish)) + ); + } + + private run.halo.aifoundation.ui.UIMessageChatResult> consoleAgentChat( + run.halo.aifoundation.chat.LanguageModel languageModel, + TestUiMessageChatRequest body, UIMessageCancellation cancellation, String modelName) { + var agentOptions = body.effectiveAgent(); + var diagnostics = consoleAgentDiagnostics(body, agentOptions); + var preparationCount = new AtomicInteger(); + var testTools = consoleAgentTestTools(agentOptions); + var activeTools = testTools.stream().map(ToolDefinition::getName).toList(); + var definition = AgentOptions.forModel(languageModel, ConsoleAgentCallOptions.class) + .id("console-model-test-agent") + .instructions(body.getSystem()) + .tools(testTools) + .activeTools(activeTools) + .toolChoice(body.getToolChoice()) + .output(body.getOutput()) + .stopWhen(StopCondition.stepCountIs(agentOptions.effectiveMaxSteps())) + .prepareStep(context -> consolePreparedStep(context.getStepIndex(), agentOptions, + activeTools, diagnostics)) + .toolCallRepair(consoleAgentRepair(agentOptions)) + .reasoning(body.getReasoning()) + .maxOutputTokens(body.getMaxOutputTokens()) + .temperature(body.getTemperature()) + .topP(body.getTopP()) + .topK(body.getTopK()) + .minP(body.getMinP()) + .presencePenalty(body.getPresencePenalty()) + .frequencyPenalty(body.getFrequencyPenalty()) + .repetitionPenalty(body.getRepetitionPenalty()) + .logprobs(body.getLogprobs()) + .topLogprobs(body.getTopLogprobs()) + .parallelToolCalls(body.getParallelToolCalls()) + .stopSequences(body.getStopSequences()) + .seed(body.getSeed()) + .maxRetries(body.getMaxRetries()) + .lifecycle(List.of(consoleAgentLifecycle(diagnostics))) + .callValidator(options -> { + if (options == null || options.profile() == null) { + throw new IllegalArgumentException("agent call profile must be set"); + } + }) + .prepareCall(context -> { + var count = preparationCount.incrementAndGet(); + diagnostics.put("callPreparationCount", count); + var effectiveInstructions = consoleAgentInstructions(body.getSystem(), + context.getOptions().profile()); + diagnostics.put("effectiveInstructions", effectiveInstructions); + var preparedRequest = context.getRequestBuilder().build(); + var metadata = new LinkedHashMap(); + if (preparedRequest.getMetadata() != null) { + metadata.putAll(preparedRequest.getMetadata()); + } + metadata.put("agentDiagnostics", diagnostics); + context.getRequestBuilder() + .system(effectiveInstructions) + .metadata(metadata); + return Mono.just(context.prepared()); + }) + .build(); + var agent = Agent.create(definition); + var callOptions = new ConsoleAgentCallOptions(agentOptions.effectiveProfile()); + return UIMessageChatHandlers.streamAgent(agent, toUiMessageChatRequest(body), callOptions, + options -> options + .metadataSupplier(LinkedHashMap::new) + .serializer(ModelConsoleEndpoint::writeJson) + .request(builder -> builder + .headers(body.getHeaders()) + .metadata(body.getMetadata()) + .context(body.getContext())) + .cancellationToken(cancellation.token()) + .onError(ModelConsoleEndpoint::safeMessage) + .onFinish(finish -> logUiMessageFinish(modelName, finish)) + ); + } + + private static void logUiMessageFinish(String modelName, + run.halo.aifoundation.ui.UIMessageStreamFinish> finish) { + log.debug("UI message chat test finished: modelName={}, messages={}, aborted={}, " + + "errorText={}", modelName, finish.messages().size(), finish.terminal().aborted(), + finish.terminal().errorText()); + } + private Mono uiMessageStreamResponse(UIMessageStreamResponse response, UIMessageCancellation cancellation) { Flux> flux = cancellation.cancelWhenSubscriberCancels( @@ -745,6 +847,145 @@ private GenerateTextRequest withConsoleTestTool(GenerateTextRequest request, return request; } + private List consoleAgentTestTools(TestAgentOptions options) { + var tools = new ArrayList(); + if (options.serverToolEnabled()) { + tools.add(consoleTestTool(options.approvalRequired())); + } + if (options.externalToolEnabled()) { + tools.add(consoleExternalTestTool()); + } + if (options.browserToolEnabled()) { + tools.add(consoleAgentPageContextTool()); + tools.add(consoleAgentTestActionTool()); + } + if (options.toolInputStreamEnabled()) { + tools.add(consoleToolInputStreamTestTool()); + } + if (options.effectiveRecoveryScenario() != ConsoleAgentRecoveryScenario.NONE) { + tools.add(consoleRepairTestTool()); + } + return List.copyOf(tools); + } + + @SuppressWarnings("unchecked") + private PreparedStep consolePreparedStep(Integer stepIndex, TestAgentOptions options, + List allTools, Map diagnostics) { + var index = stepIndex != null ? stepIndex : 0; + List activeTools = switch (options.effectiveStepPolicy()) { + case ALL_TOOLS -> allTools; + case SERVER_THEN_ALL -> index == 0 + ? allTools.stream().filter(this::isServerConsoleTool).toList() + : allTools; + case SERVER_THEN_BROWSER -> index == 0 + ? allTools.stream().filter(this::isServerConsoleTool).toList() + : allTools.stream().filter(name -> !isServerConsoleTool(name)).toList(); + }; + var entries = (List>) diagnostics.get("stepPreparation"); + entries.add(Map.of( + "stepIndex", index, + "activeTools", activeTools, + "policy", options.effectiveStepPolicy().name() + )); + return PreparedStep.builder().activeTools(activeTools).build(); + } + + private boolean isServerConsoleTool(String name) { + return CONSOLE_TEST_TOOL_NAME.equals(name) + || CONSOLE_REPAIR_TEST_TOOL_NAME.equals(name) + || CONSOLE_TOOL_INPUT_STREAM_TEST_TOOL_NAME.equals(name); + } + + private ToolCallRepairCallback consoleAgentRepair(TestAgentOptions options) { + return switch (options.effectiveRecoveryScenario()) { + case NONE -> null; + case INVALID_INPUT -> context -> { + if (context.getFailureKind() != ToolCallFailureKind.INVALID_INPUT + || !CONSOLE_REPAIR_TEST_TOOL_NAME.equals( + context.getToolCall().getToolName())) { + return Mono.just(ToolCallRepairResult.unrepaired()); + } + return repairedConsoleToolCall(context, CONSOLE_REPAIR_TEST_TOOL_NAME); + }; + case RENAMED_TOOL -> context -> { + if (context.getFailureKind() != ToolCallFailureKind.UNKNOWN_TOOL + || !CONSOLE_LEGACY_REPAIR_TEST_TOOL_NAME.equals( + context.getToolCall().getToolName())) { + return Mono.just(ToolCallRepairResult.unrepaired()); + } + return repairedConsoleToolCall(context, CONSOLE_REPAIR_TEST_TOOL_NAME); + }; + case FAILED_RECOVERY -> context -> Mono.just(ToolCallRepairResult.repaired( + ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName("halo_unavailable_recovery_target") + .input(context.getToolCall().getInput()) + .build())); + }; + } + + private Mono repairedConsoleToolCall( + run.halo.aifoundation.tool.ToolCallRepairContext context, String resolvedName) { + var repairedQuery = repairedConsoleQuery(context.getToolCall().getInput()); + if (repairedQuery == null || repairedQuery.isBlank()) { + return Mono.just(ToolCallRepairResult.unrepaired()); + } + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName(resolvedName) + .input(Map.of( + "query", repairedQuery, + "repairSource", "console-agent" + )) + .build())); + } + + private Map consoleAgentDiagnostics(TestUiMessageChatRequest body, + TestAgentOptions options) { + var diagnostics = new LinkedHashMap(); + diagnostics.put("enabled", true); + diagnostics.put("profile", options.effectiveProfile().name()); + diagnostics.put("maximumSteps", options.effectiveMaxSteps()); + diagnostics.put("stepPolicy", options.effectiveStepPolicy().name()); + diagnostics.put("activeTools", consoleAgentTestTools(options).stream() + .map(ToolDefinition::getName).toList()); + diagnostics.put("outputMode", body.getOutput() != null + && body.getOutput().getType() != null ? body.getOutput().getType().name() : "TEXT"); + diagnostics.put("approvalRequired", options.approvalRequired()); + diagnostics.put("externalToolEnabled", options.externalToolEnabled()); + diagnostics.put("browserToolEnabled", options.browserToolEnabled()); + diagnostics.put("recoveryScenario", options.effectiveRecoveryScenario().name()); + diagnostics.put("callPreparationCount", 0); + diagnostics.put("completedSteps", 0); + diagnostics.put("stepPreparation", new CopyOnWriteArrayList>()); + return diagnostics; + } + + private GenerationLifecycle consoleAgentLifecycle(Map diagnostics) { + return new GenerationLifecycle() { + @Override + public Mono onStepFinish(GenerationStepFinishEvent event) { + diagnostics.put("completedSteps", event.getSteps().size()); + if (event.getStep() != null && event.getStep().getFinishReason() != null) { + diagnostics.put("latestFinishReason", + event.getStep().getFinishReason().name()); + } + return Mono.empty(); + } + }; + } + + private String consoleAgentInstructions(String instructions, ConsoleAgentProfile profile) { + var base = instructions == null || instructions.isBlank() + ? "You are the Halo AI Foundation console test agent." + : instructions.trim(); + return switch (profile) { + case BALANCED -> base + " Respond clearly and use tools only when requested."; + case CONCISE -> base + " Keep the final answer concise and factual."; + case EXPLICIT -> base + " Explain the final result explicitly after every tool round."; + }; + } + private void applyConsoleGenerationOptions( GenerateTextRequest.GenerateTextRequestBuilder builder, TestUiMessageChatRequest request, ConsoleTestToolOptions options) { @@ -1024,6 +1265,14 @@ private Mono validateTestUiMessageChatRequest(TestUiMessageChatRequest req for (var message : request.getMessages()) { validateConsoleUiMessage(message); } + if (request.agentEnabled()) { + var agent = request.effectiveAgent(); + if (agent.getMaxSteps() != null + && (agent.getMaxSteps() < 1 || agent.getMaxSteps() > 100)) { + return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST, + "agent.maxSteps must be between 1 and 100")); + } + } return Mono.empty(); } @@ -1697,6 +1946,91 @@ public static class TestUiMessageChatRequest { private Map context; private run.halo.aifoundation.schema.OutputSpec output; private run.halo.aifoundation.tool.ToolChoice toolChoice; + @Schema(description = "Agent workbench execution and diagnostic options.") + private TestAgentOptions agent; + + boolean agentEnabled() { + return agent != null && Boolean.TRUE.equals(agent.getEnabled()); + } + + TestAgentOptions effectiveAgent() { + return agent != null ? agent : new TestAgentOptions(); + } + } + + public enum ConsoleAgentProfile { + BALANCED, + CONCISE, + EXPLICIT + } + + public enum ConsoleAgentStepPolicy { + ALL_TOOLS, + SERVER_THEN_ALL, + SERVER_THEN_BROWSER + } + + public enum ConsoleAgentRecoveryScenario { + NONE, + INVALID_INPUT, + RENAMED_TOOL, + FAILED_RECOVERY + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class TestAgentOptions { + private Boolean enabled; + private ConsoleAgentProfile profile; + private Integer maxSteps; + private ConsoleAgentStepPolicy stepPolicy; + private Boolean serverToolEnabled; + private Boolean browserToolEnabled; + private Boolean externalToolEnabled; + private Boolean approvalRequired; + private Boolean toolInputStreamEnabled; + private ConsoleAgentRecoveryScenario recoveryScenario; + + ConsoleAgentProfile effectiveProfile() { + return profile != null ? profile : ConsoleAgentProfile.BALANCED; + } + + int effectiveMaxSteps() { + return maxSteps != null ? maxSteps : Agent.DEFAULT_MAX_STEPS; + } + + ConsoleAgentStepPolicy effectiveStepPolicy() { + return stepPolicy != null ? stepPolicy : ConsoleAgentStepPolicy.ALL_TOOLS; + } + + ConsoleAgentRecoveryScenario effectiveRecoveryScenario() { + return recoveryScenario != null ? recoveryScenario + : ConsoleAgentRecoveryScenario.NONE; + } + + boolean serverToolEnabled() { + return serverToolEnabled == null || serverToolEnabled; + } + + boolean browserToolEnabled() { + return Boolean.TRUE.equals(browserToolEnabled); + } + + boolean externalToolEnabled() { + return Boolean.TRUE.equals(externalToolEnabled); + } + + boolean approvalRequired() { + return Boolean.TRUE.equals(approvalRequired); + } + + boolean toolInputStreamEnabled() { + return Boolean.TRUE.equals(toolInputStreamEnabled); + } + } + + private record ConsoleAgentCallOptions(ConsoleAgentProfile profile) { } @Data diff --git a/app/src/main/java/run/halo/aifoundation/service/language/tool/LanguageModelToolExecutor.java b/app/src/main/java/run/halo/aifoundation/service/language/tool/LanguageModelToolExecutor.java index 08e6afda..6107e1df 100644 --- a/app/src/main/java/run/halo/aifoundation/service/language/tool/LanguageModelToolExecutor.java +++ b/app/src/main/java/run/halo/aifoundation/service/language/tool/LanguageModelToolExecutor.java @@ -16,6 +16,7 @@ import run.halo.aifoundation.message.ModelMessage; import run.halo.aifoundation.tool.ToolApprovalRequest; import run.halo.aifoundation.tool.ToolCall; +import run.halo.aifoundation.tool.ToolCallFailureKind; import run.halo.aifoundation.tool.ToolCallRepairContext; import run.halo.aifoundation.tool.ToolDefinition; import run.halo.aifoundation.tool.ToolError; @@ -63,17 +64,8 @@ private Mono executeNext(List toolCalls, int index var toolCall = toolCalls.get(index); var resolvedCall = toolCall; try { - var tool = context.tool(toolCall); - if (tool == null) { - accumulator.addError(unknownToolError(toolCall)); - return executeNext(toolCalls, index + 1, context, accumulator); - } - if (tool.getExecutor() == null) { - accumulator.addWarning(externalToolPendingWarning(tool)); - return Mono.just(accumulator.toBatch()); - } cancellationChecker.check(context.request()); - return repairIfNeeded(toolCall, tool, context) + return resolveToolCall(toolCall, context) .flatMap(repair -> { var currentCall = repair.toolCall(); accumulator.addWarnings(repair.warnings()); @@ -81,6 +73,15 @@ private Mono executeNext(List toolCalls, int index accumulator.addError(repair.error()); return executeNext(toolCalls, index + 1, context, accumulator); } + var tool = context.tool(currentCall); + if (tool == null) { + accumulator.addError(unknownToolError(currentCall)); + return executeNext(toolCalls, index + 1, context, accumulator); + } + if (tool.getExecutor() == null) { + accumulator.addWarning(externalToolPendingWarning(tool)); + return Mono.just(accumulator.toBatch()); + } return executeOne(currentCall, tool, context) .flatMap(outcome -> { if (outcome.error() != null) { @@ -148,21 +149,22 @@ private Mono normalizeNext(List toolCalls, int var toolCall = toolCalls.get(index); try { var tool = context.tool(toolCall); - if (tool == null) { - accumulator.addInvalidToolCall(toolCall, unknownToolError(toolCall)); - return normalizeNext(toolCalls, index + 1, context, accumulator); - } cancellationChecker.check(context.request()); return notifyInputStartUnlessStreamed(toolCall, tool, context) - .then(Mono.defer(() -> repairIfNeeded(toolCall, tool, context))) + .then(Mono.defer(() -> resolveToolCall(toolCall, context))) .flatMap(repair -> { accumulator.addWarnings(repair.warnings()); if (repair.error() != null) { accumulator.addInvalidToolCall(toolCall, repair.error()); - } else { - accumulator.addToolCall(repair.toolCall()); + return normalizeNext(toolCalls, index + 1, context, accumulator); } - return normalizeNext(toolCalls, index + 1, context, accumulator); + var resolvedTool = context.tool(repair.toolCall()); + return replayResolvedInputIfNeeded(toolCall, repair.toolCall(), resolvedTool, + context) + .then(Mono.defer(() -> { + accumulator.addToolCall(repair.toolCall()); + return normalizeNext(toolCalls, index + 1, context, accumulator); + })); }); } catch (RuntimeException e) { accumulator.addInvalidToolCall(toolCall, toolError(toolCall, e)); @@ -172,6 +174,9 @@ private Mono normalizeNext(List toolCalls, int private Mono notifyInputStartUnlessStreamed(ToolCall toolCall, ToolDefinition tool, ToolStepContext context) { + if (tool == null) { + return Mono.empty(); + } if (context.streamedInputCallIds().contains(toolCall.getToolCallId())) { return Mono.empty(); } @@ -180,6 +185,28 @@ private Mono notifyInputStartUnlessStreamed(ToolCall toolCall, ToolDefinit context.providerMetadata(toolCall)); } + private Mono replayResolvedInputIfNeeded(ToolCall original, ToolCall resolved, + ToolDefinition resolvedTool, ToolStepContext context) { + if (context.tool(original) != null || resolvedTool == null) { + return Mono.empty(); + } + var start = inputStart(resolved.getToolCallId(), resolved.getToolName(), resolvedTool, + context.request(), context.stepIndex(), context.executionMessages(), + context.providerMetadata(resolved)); + if (!context.streamedInputCallIds().contains(original.getToolCallId())) { + return start; + } + var rawInput = resolved.getRawInput() != null + ? resolved.getRawInput() + : original.getRawInput(); + if (!(rawInput instanceof String inputText) || inputText.isEmpty()) { + return start; + } + return start.then(inputDelta(resolved.getToolCallId(), resolved.getToolName(), inputText, + resolvedTool, context.request(), context.stepIndex(), context.executionMessages(), + context.providerMetadata(resolved))); + } + public Mono evaluateApproval(List toolCalls, GenerateTextRequest request, int stepIndex, List executionMessages, Map stepProviderMetadata, ToolLifecycle lifecycle, @@ -399,31 +426,41 @@ private void validateOutput(ToolCall toolCall, ToolDefinition tool, Object value } } - private Mono repairIfNeeded(ToolCall toolCall, ToolDefinition tool, - ToolStepContext context) { + private Mono resolveToolCall(ToolCall toolCall, ToolStepContext context) { + var tool = context.tool(toolCall); + if (tool == null) { + return repair(toolCall, null, ToolCallFailureKind.UNKNOWN_TOOL, context, + new IllegalArgumentException("Unknown tool: " + toolCall.getToolName())); + } var parseError = toolCall.getInputParseError(); if (parseError != null) { - return repair(toolCall, tool, context, + return repair(toolCall, tool, ToolCallFailureKind.INVALID_INPUT, context, new IllegalArgumentException(parseError.getMessage())); } try { validateInput(toolCall, tool); return Mono.just(new RepairAttempt(toolCall, null, List.of())); } catch (RuntimeException validationFailure) { - return repair(toolCall, tool, context, validationFailure); + return repair(toolCall, tool, ToolCallFailureKind.INVALID_INPUT, context, + validationFailure); } } private Mono repair(ToolCall toolCall, ToolDefinition tool, - ToolStepContext context, RuntimeException validationFailure) { + ToolCallFailureKind failureKind, ToolStepContext context, + RuntimeException validationFailure) { var repairCallback = context.request().getToolCallRepair(); - if (repairCallback == null) { + if (repairCallback == null + || (failureKind == ToolCallFailureKind.UNKNOWN_TOOL + && context.toolsByName().isEmpty())) { return Mono.just(new RepairAttempt(toolCall, toolError(toolCall, validationFailure), List.of())); } return repairCallback.repair(ToolCallRepairContext.builder() + .failureKind(failureKind) .toolCall(toolCall) .tool(tool) + .availableTools(List.copyOf(context.toolsByName().values())) .validationError(safeErrorMessage(validationFailure)) .validationPath(validationPath(toolCall)) .stepIndex(context.stepIndex()) @@ -432,12 +469,16 @@ private Mono repair(ToolCall toolCall, ToolDefinition tool, .providerMetadata(mergeProviderMetadata(context.stepProviderMetadata(), toolCall.getProviderMetadata())) .build()) - .map(result -> repairedAttempt(toolCall, tool, validationFailure, result)) - .onErrorResume(RuntimeException.class, repairFailure -> + .map(result -> repairedAttempt(toolCall, tool, failureKind, context, + validationFailure, result)) + .defaultIfEmpty(failedRepairAttempt(toolCall, validationFailure, + new IllegalStateException("Tool call recovery returned no result"))) + .onErrorResume(Throwable.class, repairFailure -> Mono.just(failedRepairAttempt(toolCall, validationFailure, repairFailure))); } private RepairAttempt repairedAttempt(ToolCall toolCall, ToolDefinition tool, + ToolCallFailureKind failureKind, ToolStepContext context, RuntimeException validationFailure, run.halo.aifoundation.tool.ToolCallRepairResult result) { var warnings = new ArrayList(); @@ -447,9 +488,19 @@ private RepairAttempt repairedAttempt(ToolCall toolCall, ToolDefinition tool, return new RepairAttempt(toolCall, toolError(toolCall, validationFailure), warnings); } try { - var normalized = normalizeRepairedCall(toolCall, repaired); - validateInput(normalized, tool); - warnings.add(repairedWarning(toolCall, normalized)); + var normalized = normalizeRepairedCall(toolCall, repaired, failureKind); + var resolvedTool = context.tool(normalized); + if (resolvedTool == null) { + throw new IllegalArgumentException( + "Recovered tool is not currently available: " + normalized.getToolName()); + } + if (failureKind == ToolCallFailureKind.INVALID_INPUT + && resolvedTool != tool) { + throw new IllegalArgumentException( + "Invalid-input recovery cannot change the tool name"); + } + validateInput(normalized, resolvedTool); + warnings.add(repairedWarning(toolCall, normalized, failureKind)); return new RepairAttempt(normalized, null, warnings); } catch (RuntimeException repairFailure) { return failedRepairAttempt(toolCall, validationFailure, repairFailure); @@ -469,10 +520,29 @@ private Map copyContext(Map context) { return Collections.unmodifiableMap(new LinkedHashMap<>(context)); } - private ToolCall normalizeRepairedCall(ToolCall original, ToolCall repaired) { + private ToolCall normalizeRepairedCall(ToolCall original, ToolCall repaired, + ToolCallFailureKind failureKind) { + if (original.getToolCallId() == null || original.getToolCallId().isBlank()) { + throw new IllegalArgumentException("Original tool call id must not be blank"); + } + if (repaired.getToolCallId() != null + && !original.getToolCallId().equals(repaired.getToolCallId())) { + throw new IllegalArgumentException("Recovered tool call id must match the original id"); + } + var repairedName = repaired.getToolName(); + if (failureKind == ToolCallFailureKind.INVALID_INPUT + && repairedName != null && !original.getToolName().equals(repairedName)) { + throw new IllegalArgumentException( + "Invalid-input recovery cannot change the tool name"); + } + var resolvedName = failureKind == ToolCallFailureKind.UNKNOWN_TOOL + ? repairedName : original.getToolName(); + if (resolvedName == null || resolvedName.isBlank()) { + throw new IllegalArgumentException("Recovered tool name must not be blank"); + } return ToolCall.builder() .toolCallId(original.getToolCallId()) - .toolName(original.getToolName()) + .toolName(resolvedName) .input(repaired.getInput() != null ? repaired.getInput() : Map.of()) .rawInput(repaired.getRawInput() != null ? repaired.getRawInput() : original.getRawInput()) .inputParseError(null) @@ -481,13 +551,20 @@ private ToolCall normalizeRepairedCall(ToolCall original, ToolCall repaired) { .build(); } - private GenerationWarning repairedWarning(ToolCall original, ToolCall repaired) { + private GenerationWarning repairedWarning(ToolCall original, ToolCall repaired, + ToolCallFailureKind failureKind) { + var renamed = failureKind == ToolCallFailureKind.UNKNOWN_TOOL; return GenerationWarning.builder() .code(WARNING_TOOL_CALL_REPAIRED) - .message("Tool call input was repaired before execution: " + original.getToolName()) + .message(renamed + ? "Unknown tool call was recovered before execution: " + + original.getToolName() + " -> " + repaired.getToolName() + : "Tool call input was repaired before execution: " + original.getToolName()) .providerMetadata(Map.of( "toolCallId", repaired.getToolCallId(), - "toolName", repaired.getToolName() + "originalToolName", original.getToolName(), + "resolvedToolName", repaired.getToolName(), + "failureKind", failureKind.name() )) .build(); } @@ -498,7 +575,7 @@ private GenerationWarning repairFailedWarning(ToolCall toolCall, Throwable e) { .message("Tool call input repair failed: " + safeErrorMessage(e)) .providerMetadata(Map.of( "toolCallId", toolCall.getToolCallId(), - "toolName", toolCall.getToolName() + "originalToolName", toolCall.getToolName() )) .build(); } diff --git a/app/src/test/java/run/halo/aifoundation/agent/AgentDocumentationTest.java b/app/src/test/java/run/halo/aifoundation/agent/AgentDocumentationTest.java new file mode 100644 index 00000000..7c86b7ed --- /dev/null +++ b/app/src/test/java/run/halo/aifoundation/agent/AgentDocumentationTest.java @@ -0,0 +1,59 @@ +package run.halo.aifoundation.agent; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AgentDocumentationTest { + + @Test + void bilingualGuidesNavigationAndSkillMapReferenceThePublishedAgentSurface() + throws IOException { + var root = repositoryRoot(); + var chineseGuide = read(root, "dev/zh-CN/sdk-core/agents.md"); + var englishGuide = read(root, "dev/en/sdk-core/agents.md"); + for (var type : List.of("Agent", "AgentOptions", "AgentCall", "PreparedAgentCall", + "ToolCallFailureKind", "UIMessageChatHandlers.streamAgent")) { + assertThat(chineseGuide).contains(type); + assertThat(englishGuide).contains(type); + } + assertThat(read(root, "dev/zh-CN/sdk-core/README.md")).contains("./agents.md"); + assertThat(read(root, "dev/en/sdk-core/README.md")).contains("./agents.md"); + assertThat(read(root, "dev/zh-CN/sdk-core/api-reference.md")) + .contains("AgentCallPrepareContext", "ToolCallFailureKind"); + assertThat(read(root, "dev/en/sdk-core/api-reference.md")) + .contains("AgentCallPrepareContext", "ToolCallFailureKind"); + assertThat(read(root, "skills/use-ai-foundation-sdk/references/sdk-map.md")) + .contains("sdk-core/agents.md", "aifoundation/agent/"); + } + + @Test + void everyDocumentedAgentTopLevelTypeHasPublicSource() { + var root = repositoryRoot(); + for (var type : List.of("Agent", "AgentOptions", "AgentCall", "AgentCallValidator", + "AgentCallPrepare", "AgentCallPrepareContext", "PreparedAgentCall", + "AgentCallPhase", "AgentCallException")) { + assertThat(root.resolve("api/src/main/java/run/halo/aifoundation/agent/" + + type + ".java")) + .as("published source for %s", type) + .isRegularFile(); + } + } + + private String read(Path root, String relativePath) throws IOException { + return Files.readString(root.resolve(relativePath)); + } + + private Path repositoryRoot() { + var current = Path.of("").toAbsolutePath(); + while (current != null && !Files.isRegularFile(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + assertThat(current).as("repository root").isNotNull(); + return current; + } +} diff --git a/app/src/test/java/run/halo/aifoundation/agent/AgentRuntimeTest.java b/app/src/test/java/run/halo/aifoundation/agent/AgentRuntimeTest.java new file mode 100644 index 00000000..73a01a1d --- /dev/null +++ b/app/src/test/java/run/halo/aifoundation/agent/AgentRuntimeTest.java @@ -0,0 +1,416 @@ +package run.halo.aifoundation.agent; + +import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Field; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.aifoundation.chat.GenerateTextRequest; +import run.halo.aifoundation.chat.GenerateTextResult; +import run.halo.aifoundation.chat.LanguageModel; +import run.halo.aifoundation.chat.LanguageModelCapabilities; +import run.halo.aifoundation.chat.ReasoningOptions; +import run.halo.aifoundation.chat.StepContext; +import run.halo.aifoundation.chat.StopCondition; +import run.halo.aifoundation.chat.StreamTextResult; +import run.halo.aifoundation.control.CancellationSource; +import run.halo.aifoundation.exception.AiGenerationCancelledException; +import run.halo.aifoundation.message.ModelMessage; +import run.halo.aifoundation.part.TextStreamPart; +import run.halo.aifoundation.schema.OutputSpec; +import run.halo.aifoundation.tool.ToolCallFailureKind; +import run.halo.aifoundation.tool.ToolCallRepairContext; +import run.halo.aifoundation.tool.ToolCallRepairResult; +import run.halo.aifoundation.tool.ToolChoice; +import run.halo.aifoundation.tool.ToolDefinition; + +class AgentRuntimeTest { + + @Test + void noOptionsAgentComposesFreshBoundedRequest() { + var model = new RecordingModel("base"); + var agent = Agent.create(model, "Be concise"); + + StepVerifier.create(agent.generate(AgentCall.prompt("Hello"))) + .assertNext(result -> assertThat(result.getText()).isEqualTo("base")) + .verifyComplete(); + + var request = model.requests.getFirst(); + assertThat(request.getSystem()).isEqualTo("Be concise"); + assertThat(request.getPrompt()).isEqualTo("Hello"); + assertThat(request.getStopWhen()).isNotNull(); + assertThat(request.getStopWhen().shouldContinue(StepContext.builder() + .stepIndex(18) + .build())).isTrue(); + assertThat(request.getStopWhen().shouldContinue(StepContext.builder() + .stepIndex(19) + .build())).isFalse(); + } + + @Test + void typedValidationRunsBeforeOneTimePreparationAndCanReplaceModel() { + record CallOptions(String profile) { + } + var base = new RecordingModel("base"); + var selected = new RecordingModel("selected"); + var order = new CopyOnWriteArrayList(); + var agent = Agent.create(AgentOptions.forModel(base, CallOptions.class) + .instructions("base instructions") + .callValidator(options -> { + order.add("validate"); + if (options == null || options.profile().isBlank()) { + throw new IllegalArgumentException("profile is required"); + } + }) + .prepareCall(context -> { + order.add("prepare"); + context.getRequestBuilder().system("profile=" + context.getOptions().profile()); + return Mono.just(context.prepared(selected)); + }) + .build()); + + StepVerifier.create(agent.generate(AgentCall.prompt("Hello", new CallOptions("fast")))) + .assertNext(result -> assertThat(result.getText()).isEqualTo("selected")) + .verifyComplete(); + + assertThat(order).containsExactly("validate", "prepare"); + assertThat(base.requests).isEmpty(); + assertThat(selected.requests.getFirst().getSystem()).isEqualTo("profile=fast"); + } + + @Test + void validationAndPreparationFailuresArePhaseSpecificAndPreProvider() { + var model = new RecordingModel("unused"); + var preparations = new AtomicInteger(); + var agent = Agent.create(AgentOptions.forModel(model, String.class) + .callValidator(options -> { + if (!"valid".equals(options)) { + throw new IllegalArgumentException("invalid profile"); + } + }) + .prepareCall(context -> { + preparations.incrementAndGet(); + return Mono.error(new IllegalStateException("lookup failed")); + }) + .build()); + + StepVerifier.create(agent.generate(AgentCall.prompt("Hello", "invalid"))) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(AgentCallException.class); + assertThat(((AgentCallException) error).getPhase()) + .isEqualTo(AgentCallPhase.VALIDATION); + }) + .verify(); + assertThat(preparations).hasValue(0); + + StepVerifier.create(agent.generate(AgentCall.prompt("Hello", "valid"))) + .expectErrorSatisfies(error -> { + assertThat(error).isInstanceOf(AgentCallException.class); + assertThat(((AgentCallException) error).getPhase()) + .isEqualTo(AgentCallPhase.PREPARATION); + }) + .verify(); + assertThat(model.requests).isEmpty(); + } + + @Test + void definitionAndCallControlsComposeWithDocumentedPrecedence() { + var model = new RecordingModel("ok"); + var definitionMiddleware = new NoopMiddleware(); + var callMiddleware = new NoopMiddleware(); + var definitionContext = new LinkedHashMap(); + definitionContext.put("shared", "definition"); + definitionContext.put("nullable", null); + var agent = Agent.create(AgentOptions.forModel(model) + .headers(Map.of("shared", "definition", "definition", "yes")) + .metadata(Map.of("definition", true)) + .context(definitionContext) + .middleware(List.of(definitionMiddleware)) + .timeouts(run.halo.aifoundation.chat.GenerationTimeouts.builder() + .stepTimeout(Duration.ofSeconds(20)) + .toolTimeout(Duration.ofSeconds(10)) + .build()) + .prepareCall(context -> { + context.getRequestBuilder().temperature(0.25); + return Mono.just(context.prepared()); + }) + .build()); + var call = AgentCall.builder() + .prompt("Hello") + .headers(Map.of("shared", "call")) + .metadata(Map.of("call", true)) + .context(Collections.singletonMap("nullable", null)) + .middleware(List.of(callMiddleware)) + .timeouts(run.halo.aifoundation.chat.GenerationTimeouts.builder() + .totalTimeout(Duration.ofMinutes(1)) + .build()) + .build(); + + agent.generate(call).block(); + + var request = model.requests.getFirst(); + assertThat(request.getHeaders()) + .containsEntry("shared", "call") + .containsEntry("definition", "yes"); + assertThat(request.getMetadata()).containsKeys("definition", "call"); + assertThat(request.getContext()).containsEntry("nullable", null); + assertThat(request.getTemperature()).isEqualTo(0.25); + assertThat(request.getMiddleware()) + .containsExactly(definitionMiddleware, callMiddleware); + assertThat(request.getTimeouts().getTotalTimeout()).isEqualTo(Duration.ofMinutes(1)); + assertThat(request.getTimeouts().getStepTimeout()).isEqualTo(Duration.ofSeconds(20)); + assertThat(request.getTimeouts().getToolTimeout()).isEqualTo(Duration.ofSeconds(10)); + } + + @Test + void definitionAndReturnedOptionsAreDefensiveSnapshots() { + var model = new RecordingModel("ok"); + var tool = ToolDefinition.builder() + .name("weather") + .inputSchema(Map.of("type", "object")) + .build(); + var tools = new ArrayList<>(List.of(tool)); + var agent = Agent.create(AgentOptions.forModel(model) + .tools(tools) + .activeTools(List.of("weather")) + .build()); + + tools.clear(); + tool.setName("changed"); + agent.options().getTools().getFirst().setName("returned-snapshot-change"); + agent.generate(AgentCall.prompt("Hello")).block(); + + assertThat(model.requests.getFirst().getTools()) + .extracting(ToolDefinition::getName) + .containsExactly("weather"); + assertThat(model.requests.getFirst().getPrepareStep().prepare( + StepContext.builder().stepIndex(0).build()).getActiveTools()) + .containsExactly("weather"); + } + + @Test + void activeToolsDistinguishesUnspecifiedFromExplicitlyEmpty() { + var model = new RecordingModel("ok"); + var unspecified = Agent.create(AgentOptions.forModel(model) + .prepareStep(context -> null) + .build()); + var disabled = Agent.create(AgentOptions.forModel(model) + .activeTools(List.of()) + .build()); + + unspecified.generate(AgentCall.prompt("all tools remain available")).block(); + disabled.generate(AgentCall.prompt("no tools are available")).block(); + + var unspecifiedRequest = model.requests.get(0); + var disabledRequest = model.requests.get(1); + assertThat(unspecified.options().getActiveTools()).isNull(); + assertThat(unspecifiedRequest.getPrepareStep().prepare( + StepContext.builder().stepIndex(0).build())).isNull(); + assertThat(disabled.options().getActiveTools()).isEmpty(); + assertThat(disabledRequest.getPrepareStep().prepare( + StepContext.builder().stepIndex(0).build()).getActiveTools()).isEmpty(); + } + + @Test + void streamViewsSharePreparationAndStreamInvocation() { + var model = new RecordingModel("streamed"); + var preparations = new AtomicInteger(); + var agent = Agent.create(AgentOptions.forModel(model) + .prepareCall(context -> { + preparations.incrementAndGet(); + return Mono.just(context.prepared()); + }) + .build()); + var stream = agent.stream(AgentCall.prompt("Hello")); + + assertThat(stream.textStream().collectList().block()).containsExactly("streamed"); + assertThat(stream.result().block().getText()).isEqualTo("streamed"); + assertThat(stream.fullStream().collectList().block()).isNotEmpty(); + assertThat(preparations).hasValue(1); + assertThat(model.streamCalls).hasValue(1); + } + + @Test + void customStopPolicyAndSemanticSettingsReachGenerateAndStreamUnchanged() { + var model = new RecordingModel("same result"); + StopCondition customStop = context -> context.getStepIndex() < 2; + var tool = ToolDefinition.builder().name("lookup").build(); + var recovery = (run.halo.aifoundation.tool.ToolCallRepairCallback) context -> + Mono.just(ToolCallRepairResult.unrepaired()); + var output = OutputSpec.json(); + var agent = Agent.create(AgentOptions.forModel(model) + .instructions("policy") + .tools(List.of(tool)) + .toolChoice(ToolChoice.required()) + .output(output) + .stopWhen(customStop) + .toolCallRepair(recovery) + .reasoning(ReasoningOptions.disabled()) + .maxOutputTokens(300) + .temperature(0.2) + .topP(0.9) + .topK(20) + .minP(0.05) + .presencePenalty(0.1) + .frequencyPenalty(0.2) + .repetitionPenalty(1.1) + .logprobs(true) + .topLogprobs(3) + .parallelToolCalls(false) + .stopSequences(List.of("END")) + .seed(7) + .maxRetries(1) + .build()); + + var generated = agent.generate(AgentCall.prompt("Hello")).block(); + var streamed = agent.stream(AgentCall.prompt("Hello")).result().block(); + + assertThat(generated).usingRecursiveComparison().isEqualTo(streamed); + assertThat(model.requests).hasSize(2).allSatisfy(request -> { + assertThat(request.getStopWhen()).isSameAs(customStop); + assertThat(request.getToolCallRepair()).isSameAs(recovery); + assertThat(request.getTools()).extracting(ToolDefinition::getName) + .containsExactly("lookup"); + assertThat(request.getToolChoice().getType()).isEqualTo(ToolChoice.Type.REQUIRED); + assertThat(request.getOutput().getType()).isEqualTo(output.getType()); + assertThat(request.getReasoning().getMode()) + .isEqualTo(ReasoningOptions.Mode.DISABLED); + assertThat(request.getMaxOutputTokens()).isEqualTo(300); + assertThat(request.getTemperature()).isEqualTo(0.2); + assertThat(request.getStopSequences()).containsExactly("END"); + assertThat(request.getSeed()).isEqualTo(7); + assertThat(request.getMaxRetries()).isEqualTo(1); + }); + } + + @Test + void concurrentCallsKeepOptionsAndCancellationIsolated() { + record CallOptions(String value) { + } + var model = new RecordingModel("ok"); + var agent = Agent.create(AgentOptions.forModel(model, CallOptions.class) + .prepareCall(context -> { + context.getRequestBuilder().system(context.getOptions().value()); + return Mono.just(context.prepared()); + }) + .build()); + var cancelled = new CancellationSource(); + cancelled.cancel(); + + StepVerifier.create(Flux.merge( + agent.generate(AgentCall.prompt("one", new CallOptions("one"))), + agent.generate(AgentCall.builder() + .prompt("cancelled") + .options(new CallOptions("cancelled")) + .cancellationToken(cancelled.token()) + .build()).onErrorResume(AiGenerationCancelledException.class, error -> + Mono.empty()), + agent.generate(AgentCall.prompt("two", new CallOptions("two")))) + .collectList()) + .assertNext(results -> assertThat(results).hasSize(2)) + .verifyComplete(); + + assertThat(model.requests) + .extracting(GenerateTextRequest::getSystem) + .containsExactlyInAnyOrder("one", "two"); + } + + @Test + void publicAgentAndRecoveryContractsDoNotExposeImplementationTypes() { + var publicTypes = List.of( + Agent.class, + AgentOptions.class, + AgentCall.class, + AgentCallValidator.class, + AgentCallPrepare.class, + AgentCallPrepareContext.class, + PreparedAgentCall.class, + ToolCallRepairContext.class, + ToolCallFailureKind.class + ); + + var signatures = publicTypes.stream() + .flatMap(type -> Flux.concat( + Flux.fromArray(type.getDeclaredFields()).map(Field::getGenericType), + Flux.fromArray(type.getDeclaredMethods()) + .flatMap(method -> Flux.concat( + Flux.just(method.getGenericReturnType()), + Flux.fromArray(method.getGenericParameterTypes()))), + Flux.fromArray(type.getDeclaredConstructors()) + .flatMap(constructor -> Flux.fromArray( + constructor.getGenericParameterTypes()))) + .toStream()) + .map(Type::getTypeName) + .toList(); + + assertThat(signatures) + .noneMatch(name -> name.startsWith("org.springframework") + || name.contains("run.halo.aifoundation.service") + || name.contains("run.halo.aifoundation.provider")); + } + + private static final class NoopMiddleware implements + run.halo.aifoundation.chat.middleware.LanguageModelMiddleware { + } + + private static final class RecordingModel implements LanguageModel { + private final String text; + private final List requests = new CopyOnWriteArrayList<>(); + private final AtomicInteger streamCalls = new AtomicInteger(); + + private RecordingModel(String text) { + this.text = text; + } + + @Override + public Mono generateText(String prompt) { + return generateText(GenerateTextRequest.builder().prompt(prompt).build()); + } + + @Override + public Mono generateText(GenerateTextRequest request) { + return Mono.defer(() -> { + requests.add(request); + return Mono.just(result()); + }); + } + + @Override + public StreamTextResult streamText(GenerateTextRequest request) { + requests.add(request); + streamCalls.incrementAndGet(); + var full = Flux.just( + TextStreamPart.start("message-1"), + TextStreamPart.textStart("text-1"), + TextStreamPart.textDelta("text-1", text), + TextStreamPart.textEnd("text-1") + ).cache(); + return new StreamTextResult(full, Flux.just(text), Flux.empty(), Flux.empty(), + Mono.empty(), Mono.just(result())); + } + + @Override + public LanguageModelCapabilities capabilities() { + return LanguageModelCapabilities.defaults(); + } + + private GenerateTextResult result() { + return GenerateTextResult.builder() + .text(text) + .steps(List.of()) + .warnings(List.of()) + .responseMessages(List.of(ModelMessage.assistant(text))) + .build(); + } + } +} diff --git a/app/src/test/java/run/halo/aifoundation/endpoint/ModelConsoleEndpointTest.java b/app/src/test/java/run/halo/aifoundation/endpoint/ModelConsoleEndpointTest.java index da38c47a..3d2b6cd8 100644 --- a/app/src/test/java/run/halo/aifoundation/endpoint/ModelConsoleEndpointTest.java +++ b/app/src/test/java/run/halo/aifoundation/endpoint/ModelConsoleEndpointTest.java @@ -21,8 +21,10 @@ import run.halo.aifoundation.AiModelService; import run.halo.aifoundation.chat.FinishReason; import run.halo.aifoundation.chat.GenerateTextRequest; +import run.halo.aifoundation.chat.GenerationRequestMetadata; import run.halo.aifoundation.chat.LanguageModel; import run.halo.aifoundation.chat.LanguageModelCapabilities; +import run.halo.aifoundation.chat.StepContext; import run.halo.aifoundation.chat.middleware.LanguageModelMiddlewares; import run.halo.aifoundation.image.GenerateImageRequest; import run.halo.aifoundation.image.GenerateImageResult; @@ -575,6 +577,165 @@ void testUiMessageChatStream_usesUiMessageChatRequestAndResponseHeader() { .isEqualTo("Hello"); } + @Test + void testUiMessageChatStream_agentModeAppliesCompletePublishedAgentPolicy() { + var languageModel = mock(LanguageModel.class); + when(aiModelService.languageModel("gpt-4")).thenReturn(Mono.just(languageModel)); + when(languageModel.streamText(any(GenerateTextRequest.class))) + .thenReturn(streamResult(Flux.just( + TextStreamPart.start("assistant-agent"), + TextStreamPart.textStart("text-agent"), + TextStreamPart.textDelta("text-agent", "Agent result"), + TextStreamPart.textEnd("text-agent"), + TextStreamPart.finish(FinishReason.STOP, "stop", null) + ))); + + webTestClient.post().uri("/models/gpt-4/test-chat/ui-message/stream") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.ofEntries( + Map.entry("id", "chat-agent"), + Map.entry("messages", List.of(Map.of( + "id", "user-agent", + "role", "user", + "parts", List.of(Map.of( + "type", "text", + "id", "user-agent-text", + "text", "Run the agent" + )) + ))), + Map.entry("system", "Base instructions."), + Map.entry("temperature", 0.3), + Map.entry("maxOutputTokens", 256), + Map.entry("headers", Map.of("X-Agent-Test", "true")), + Map.entry("metadata", Map.of("tenant", "console")), + Map.entry("context", Map.of("requestSource", "workbench")), + Map.entry("output", Map.of("type", "JSON")), + Map.entry("agent", Map.ofEntries( + Map.entry("enabled", true), + Map.entry("profile", "CONCISE"), + Map.entry("maxSteps", 9), + Map.entry("stepPolicy", "SERVER_THEN_ALL"), + Map.entry("serverToolEnabled", true), + Map.entry("browserToolEnabled", true), + Map.entry("externalToolEnabled", true), + Map.entry("approvalRequired", true), + Map.entry("toolInputStreamEnabled", true), + Map.entry("recoveryScenario", "RENAMED_TOOL") + )) + )) + .exchange() + .expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM) + .expectBody(String.class) + .consumeWith(response -> assertThat(response.getResponseBody()) + .contains("Agent result") + .contains("[DONE]")); + + var captor = ArgumentCaptor.forClass(GenerateTextRequest.class); + verify(languageModel).streamText(captor.capture()); + var request = captor.getValue(); + assertThat(request.getSystem()) + .isEqualTo("Base instructions. Keep the final answer concise and factual."); + assertThat(request.getTemperature()).isEqualTo(0.3); + assertThat(request.getMaxOutputTokens()).isEqualTo(256); + assertThat(request.getHeaders()).containsEntry("X-Agent-Test", "true"); + assertThat(request.getContext()).containsEntry("requestSource", "workbench"); + assertThat(request.getCancellationToken()).isNotNull(); + assertThat(request.getTools()).extracting(run.halo.aifoundation.tool.ToolDefinition::getName) + .containsExactly( + "halo_test_info", + "halo_external_test_info", + "get_current_page_context", + "halo_agent_test_action", + "halo_tool_input_stream_test", + "halo_repair_test_info" + ); + assertThat(request.getTools().getFirst().getApprovalPolicy().getMode().name()) + .isEqualTo("ALWAYS"); + assertThat(request.getOutput().getType().name()).isEqualTo("JSON"); + assertThat(request.getStopWhen().shouldContinue(StepContext.builder().stepIndex(7).build())) + .isTrue(); + assertThat(request.getStopWhen().shouldContinue(StepContext.builder().stepIndex(8).build())) + .isFalse(); + assertThat(request.getMetadata()).containsEntry("tenant", "console"); + assertThat(request.getMetadata()).containsKey("agentDiagnostics"); + @SuppressWarnings("unchecked") + var diagnostics = (Map) request.getMetadata().get("agentDiagnostics"); + assertThat(diagnostics) + .containsEntry("profile", "CONCISE") + .containsEntry("maximumSteps", 9) + .containsEntry("stepPolicy", "SERVER_THEN_ALL") + .containsEntry("callPreparationCount", 1) + .containsEntry("recoveryScenario", "RENAMED_TOOL"); + } + + @Test + void testUiMessageChatStream_agentDiagnosticsAreSafelySerializedOutsideAnswerText() { + var languageModel = mock(LanguageModel.class); + when(aiModelService.languageModel("gpt-4")).thenReturn(Mono.just(languageModel)); + when(languageModel.streamText(any(GenerateTextRequest.class))).thenAnswer(invocation -> { + GenerateTextRequest request = invocation.getArgument(0); + return streamResult(Flux.just( + TextStreamPart.finishStep(0, FinishReason.STOP, "stop", null, List.of(), + GenerationRequestMetadata.builder() + .id("request-agent") + .model("gpt-4") + .metadata(request.getMetadata()) + .build(), + null, Map.of()), + TextStreamPart.finish(FinishReason.STOP, "stop", null) + )); + }); + + webTestClient.post().uri("/models/gpt-4/test-chat/ui-message/stream") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of( + "id", "chat-agent-diagnostics", + "messages", List.of(Map.of( + "id", "user-agent", + "role", "user", + "parts", List.of(Map.of( + "type", "text", + "id", "user-agent-text", + "text", "Hello" + )) + )), + "agent", Map.of( + "enabled", true, + "profile", "EXPLICIT", + "recoveryScenario", "FAILED_RECOVERY" + ) + )) + .exchange() + .expectStatus().isOk() + .expectBody(String.class) + .consumeWith(response -> assertThat(response.getResponseBody()) + .contains("\"type\":\"finish-step\"") + .contains("\"agentDiagnostics\"") + .contains("\"callPreparationCount\":1") + .contains("\"recoveryScenario\":\"FAILED_RECOVERY\"") + .doesNotContain("CopyOnWriteArrayList")); + } + + @Test + void testUiMessageChatStream_agentModeRejectsInvalidStepBoundBeforeModelResolution() { + webTestClient.post().uri("/models/gpt-4/test-chat/ui-message/stream") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of( + "id", "chat-agent-invalid", + "messages", List.of(Map.of( + "id", "user-agent", + "role", "user", + "parts", List.of(Map.of("type", "text", "text", "Hello")) + )), + "agent", Map.of("enabled", true, "maxSteps", 101) + )) + .exchange() + .expectStatus().isBadRequest(); + + verify(aiModelService, never()).languageModel(any()); + } + @Test void testUiMessageChatStream_streamErrorReturnsErrorChunkAndDone() { var languageModel = mock(LanguageModel.class); diff --git a/app/src/test/java/run/halo/aifoundation/service/language/AgentLanguageModelIntegrationTest.java b/app/src/test/java/run/halo/aifoundation/service/language/AgentLanguageModelIntegrationTest.java new file mode 100644 index 00000000..cb6349fb --- /dev/null +++ b/app/src/test/java/run/halo/aifoundation/service/language/AgentLanguageModelIntegrationTest.java @@ -0,0 +1,138 @@ +package run.halo.aifoundation.service.language; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.prompt.Prompt; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.aifoundation.agent.Agent; +import run.halo.aifoundation.agent.AgentCall; +import run.halo.aifoundation.agent.AgentOptions; +import run.halo.aifoundation.tool.ToolDefinition; +import run.halo.aifoundation.ui.UIMessage; +import run.halo.aifoundation.ui.UIMessageChatHandlers; +import run.halo.aifoundation.ui.UIMessageChatRequest; +import run.halo.aifoundation.ui.UIMessageChatTrigger; +import run.halo.aifoundation.ui.UIMessageChunk; +import run.halo.aifoundation.ui.UIMessageChunkType; +import run.halo.aifoundation.ui.UIMessageParts; +import run.halo.aifoundation.ui.UIMessageRole; + +class AgentLanguageModelIntegrationTest extends LanguageModelTestSupport { + + @Test + void generateRunsToolLoopThroughProductionLanguageModel() { + var chatModel = mock(ChatModel.class); + when(chatModel.call(any(Prompt.class))).thenReturn( + toolCallResponse("call_1", "weather", "{\"location\":\"SF\"}", 2, 3), + chatResponse("It is 22C.", "stop", 4, 5) + ); + var executions = new AtomicInteger(); + var agent = weatherAgent(chatModel, executions); + + StepVerifier.create(agent.generate(AgentCall.prompt("Weather in SF?"))) + .assertNext(result -> { + assertThat(result.getText()).isEqualTo("It is 22C."); + assertThat(result.getSteps()).hasSize(2); + assertThat(result.getToolCalls()).singleElement() + .satisfies(call -> assertThat(call.getToolName()).isEqualTo("weather")); + assertThat(result.getToolResults()).singleElement() + .satisfies(toolResult -> assertThat(toolResult.getResult()) + .isEqualTo(Map.of("location", "SF", "temperature", 22))); + assertThat(result.getToolErrors()).isEmpty(); + }) + .verifyComplete(); + + assertThat(executions).hasValue(1); + verify(chatModel, times(2)).call(any(Prompt.class)); + } + + @Test + void streamRunsToolLoopOnceAndExposesConsistentProjections() { + var chatModel = mock(ChatModel.class); + when(chatModel.stream(any(Prompt.class))).thenReturn( + Flux.just(toolCallResponse("call_1", "weather", "{\"location\":\"SF\"}", 2, 3)), + Flux.just(chatResponse("It is 22C.", "stop", 4, 5)) + ); + var executions = new AtomicInteger(); + var agent = weatherAgent(chatModel, executions); + var stream = agent.stream(AgentCall.prompt("Weather in SF?")); + + StepVerifier.create(stream.textStream()) + .expectNext("It is 22C.") + .verifyComplete(); + StepVerifier.create(stream.result()) + .assertNext(result -> { + assertThat(result.getText()).isEqualTo("It is 22C."); + assertThat(result.getSteps()).hasSize(2); + assertThat(result.getToolResults()).hasSize(1); + assertThat(result.getToolErrors()).isEmpty(); + }) + .verifyComplete(); + + assertThat(executions).hasValue(1); + verify(chatModel, times(2)).stream(any(Prompt.class)); + } + + @Test + void uiMessageHandlerServesProductionAgentToolStream() { + var chatModel = mock(ChatModel.class); + when(chatModel.stream(any(Prompt.class))).thenReturn( + Flux.just(toolCallResponse("call_1", "weather", "{\"location\":\"SF\"}", 2, 3)), + Flux.just(chatResponse("It is 22C.", "stop", 4, 5)) + ); + var executions = new AtomicInteger(); + var agent = weatherAgent(chatModel, executions); + var request = new UIMessageChatRequest("chat-1", List.of( + new UIMessage<>("user-1", UIMessageRole.USER, + List.of(UIMessageParts.text("text-1", "Weather in SF?")), null) + ), UIMessageChatTrigger.SUBMIT_MESSAGE, null); + + var chat = UIMessageChatHandlers.streamAgent(agent, request, null); + var chunks = chat.response().stream().collectList().block(); + var finish = chat.finish().block(); + + assertThat(chunks).extracting(UIMessageChunk::type) + .contains( + UIMessageChunkType.TOOL_INPUT_AVAILABLE, + UIMessageChunkType.TOOL_OUTPUT_AVAILABLE, + UIMessageChunkType.TEXT_DELTA, + UIMessageChunkType.FINISH + ); + assertThat(finish.responseMessage().text()).isEqualTo("It is 22C."); + assertThat(finish.messages()).hasSize(2); + assertThat(executions).hasValue(1); + verify(chatModel, times(2)).stream(any(Prompt.class)); + } + + private Agent weatherAgent(ChatModel chatModel, AtomicInteger executions) { + var model = languageModel(chatModel, "openai"); + var weather = ToolDefinition.builder() + .name("weather") + .description("Get weather") + .inputSchema(weatherInputSchema()) + .executor(context -> { + executions.incrementAndGet(); + return Mono.just(Map.of( + "location", context.getInput().get("location"), + "temperature", 22 + )); + }) + .build(); + return Agent.create(AgentOptions.forModel(model) + .instructions("Use the weather tool, then answer the user.") + .tools(List.of(weather)) + .build()); + } +} diff --git a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java index 2bfec573..e9d7d14b 100644 --- a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java +++ b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelImplTest.java @@ -69,6 +69,7 @@ import run.halo.aifoundation.tool.ToolApprovalRequest; import run.halo.aifoundation.tool.ToolApprovalResponse; import run.halo.aifoundation.tool.ToolCall; +import run.halo.aifoundation.tool.ToolCallFailureKind; import run.halo.aifoundation.tool.ToolCallRepairResult; import run.halo.aifoundation.tool.ToolDefinition; import run.halo.aifoundation.tool.ToolExecutor; @@ -1139,7 +1140,7 @@ void generateText_missingOrFailedRepairPreservesValidationToolError() { } @Test - void generateText_doesNotRepairUnknownToolOrOutputSchemaFailure() { + void generateText_repairsUnknownToolButNotOutputSchemaFailure() { var chatModel = mock(ChatModel.class); when(chatModel.call(any(Prompt.class))).thenReturn( toolCallResponse("call_1", "unknown", "{}", 2, 3), @@ -1189,7 +1190,7 @@ void generateText_doesNotRepairUnknownToolOrOutputSchemaFailure() { .satisfies(error -> assertThat(error.getErrorText()).contains("temperature"))) .verifyComplete(); - assertThat(repairCalls).hasValue(0); + assertThat(repairCalls).hasValue(1); } @Test @@ -3263,6 +3264,82 @@ void streamText_recordsToolErrorForUnknownTool() { verify(chatModel, times(2)).stream(any(Prompt.class)); } + @Test + void streamText_recoversUnknownToolWithResolvedNameAndStableIdentity() { + var chatModel = mock(ChatModel.class); + when(chatModel.stream(any(Prompt.class))).thenReturn( + Flux.just(toolCallResponse("call_1", "legacyWeather", + "{\"location\":\"SF\"}", 2, 3)), + Flux.just(chatResponse("It is 22C.", "stop", 4, 5)) + ); + var executions = new AtomicInteger(); + var contexts = new AtomicReference(); + var request = GenerateTextRequest.builder() + .prompt("Weather") + .tools(List.of(repairableWeatherTool(context -> { + executions.incrementAndGet(); + return Mono.just(Map.of("temperature", 22)); + }))) + .toolCallRepair(context -> { + contexts.set(context); + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName("weather") + .input(context.getToolCall().getInput()) + .build())); + }) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + var stream = new LanguageModelImpl(chatModel, "openai").streamText(request); + + StepVerifier.create(stream.fullStream().collectList()) + .assertNext(parts -> { + assertThat(parts).extracting(TextStreamPart::getType) + .containsSubsequence(PartType.TOOL_CALL, PartType.TOOL_RESULT) + .doesNotContain(PartType.TOOL_INPUT_ERROR); + assertThat(parts.stream() + .filter(part -> PartType.TOOL_CALL.equals(part.getType())) + .toList()) + .singleElement() + .satisfies(part -> { + assertThat(part.getToolCallId()).isEqualTo("call_1"); + assertThat(part.getToolName()).isEqualTo("weather"); + }); + assertThat(parts.stream() + .filter(part -> PartType.TOOL_RESULT.equals(part.getType())) + .toList()) + .singleElement() + .satisfies(part -> { + assertThat(part.getToolCallId()).isEqualTo("call_1"); + assertThat(part.getToolName()).isEqualTo("weather"); + }); + assertThat(parts.stream() + .filter(part -> PartType.FINISH_STEP.equals(part.getType())) + .findFirst().orElseThrow().getWarnings()) + .extracting("code") + .contains("tool-call-repaired"); + }) + .verifyComplete(); + + StepVerifier.create(stream.result()) + .assertNext(result -> { + assertThat(result.getText()).isEqualTo("It is 22C."); + assertThat(result.getResponseMessages().stream() + .flatMap(message -> message.getContent().stream()) + .filter(part -> PartType.TOOL_CALL.equals(part.getType())) + .toList()) + .singleElement() + .satisfies(part -> { + assertThat(part.getToolCallId()).isEqualTo("call_1"); + assertThat(part.getToolName()).isEqualTo("weather"); + }); + }) + .verifyComplete(); + assertThat(contexts.get().getFailureKind()).isEqualTo(ToolCallFailureKind.UNKNOWN_TOOL); + assertThat(executions).hasValue(1); + verify(chatModel, times(2)).stream(any(Prompt.class)); + } + @Test void streamText_repairsInvalidToolInputBeforeEmittingResultAndContinuing() { var chatModel = mock(ChatModel.class); diff --git a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolInputLifecycleTest.java b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolInputLifecycleTest.java index d47cd08b..c02f5cd4 100644 --- a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolInputLifecycleTest.java +++ b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolInputLifecycleTest.java @@ -28,6 +28,7 @@ import run.halo.aifoundation.service.language.stream.ProviderStreamPart; import run.halo.aifoundation.service.language.stream.ProviderStreamingChatModel; import run.halo.aifoundation.tool.ToolCall; +import run.halo.aifoundation.tool.ToolCallFailureKind; import run.halo.aifoundation.tool.ToolCallRepairResult; import run.halo.aifoundation.tool.ToolDefinition; @@ -109,6 +110,102 @@ void streamsBackpressuredLifecycleInCanonicalOrderAndOnlyOnceAcrossViews() { assertThat(executorCount).hasValue(1); } + @Test + void streamedUnknownToolReplaysResolvedCallbacksInCanonicalOrder() { + var chatModel = providerStreamingModel(); + var provider = (ProviderStreamingChatModel) chatModel; + when(provider.streamParts(any(Prompt.class))).thenReturn( + Flux.just( + new ProviderStreamPart.ToolInputStartPart(0, "call_1", "legacyWeather"), + new ProviderStreamPart.ToolInputDeltaPart(0, "{\"location\":"), + new ProviderStreamPart.ToolInputDeltaPart(0, "\"SF\"}"), + new ProviderStreamPart.ToolInputEndPart(0), + new ProviderStreamPart.ChatResponsePart( + toolCallResponse("call_1", "legacyWeather", + "{\"location\":\"SF\"}", 2, 3)) + ), + Flux.just(new ProviderStreamPart.ChatResponsePart( + chatResponse("It is 22C.", "stop", 4, 5))) + ); + var events = new ArrayList(); + var callbacks = new AtomicInteger(); + var tool = ToolDefinition.builder() + .name("weather") + .inputSchema(weatherInputSchema()) + .onInputStart(context -> Mono.fromRunnable(() -> { + callbacks.incrementAndGet(); + events.add("callback:start:" + context.getToolName()); + })) + .onInputDelta(context -> Mono.fromRunnable(() -> { + callbacks.incrementAndGet(); + events.add("callback:delta:" + context.getInputTextDelta()); + })) + .onInputAvailable(context -> Mono.fromRunnable(() -> { + callbacks.incrementAndGet(); + events.add("callback:available:" + context.getToolName()); + })) + .executor(context -> Mono.fromSupplier(() -> { + events.add("executor:" + context.getToolName()); + return Map.of("temperature", 22); + })) + .build(); + var request = GenerateTextRequest.builder() + .prompt("Weather") + .tools(List.of(tool)) + .toolCallRepair(context -> { + assertThat(context.getFailureKind()).isEqualTo(ToolCallFailureKind.UNKNOWN_TOOL); + events.add("recovery:" + context.getToolCall().getToolName()); + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName("weather") + .input(context.getToolCall().getInput()) + .rawInput(context.getToolCall().getRawInput()) + .build())); + }) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + + var stream = languageModel(chatModel, "openai").streamText(request); + StepVerifier.create(stream.fullStream() + .doOnNext(part -> events.add("part:" + part.getType() + + ":" + part.getToolName())) + .collectList()) + .assertNext(parts -> { + assertThat(parts).extracting(TextStreamPart::getType) + .containsSubsequence( + PartType.TOOL_INPUT_START, + PartType.TOOL_INPUT_DELTA, + PartType.TOOL_INPUT_DELTA, + PartType.TOOL_INPUT_END, + PartType.TOOL_CALL, + PartType.TOOL_RESULT + ); + assertThat(parts.stream() + .filter(part -> PartType.TOOL_CALL.equals(part.getType())) + .toList()) + .singleElement() + .satisfies(part -> { + assertThat(part.getToolCallId()).isEqualTo("call_1"); + assertThat(part.getToolName()).isEqualTo("weather"); + }); + }) + .verifyComplete(); + StepVerifier.create(stream.result()) + .assertNext(result -> assertThat(result.getText()).isEqualTo("It is 22C.")) + .verifyComplete(); + + assertThat(events).containsSubsequence( + "recovery:legacyWeather", + "callback:start:weather", + "callback:delta:{\"location\":\"SF\"}", + "part:tool-call:weather", + "callback:available:weather", + "executor:weather", + "part:tool-result:weather" + ); + assertThat(callbacks).hasValue(3); + } + @Test void doesNotPublishInputOrReadAheadWhileCallbackIsPending() { var chatModel = providerStreamingModel(); @@ -361,6 +458,100 @@ void serializesInterleavedToolInputCallbacksGlobally() { ); } + @Test + void recoversInterleavedUnknownStreamsWithoutCrossingCallIdentity() { + var chatModel = providerStreamingModel(); + when(((ProviderStreamingChatModel) chatModel).streamParts(any(Prompt.class))) + .thenReturn( + Flux.just( + new ProviderStreamPart.ToolInputStartPart(0, "call_1", "legacyFirst"), + new ProviderStreamPart.ToolInputStartPart(1, "call_2", "legacySecond"), + new ProviderStreamPart.ToolInputDeltaPart(0, "{\"value\":1}"), + new ProviderStreamPart.ToolInputDeltaPart(1, "{\"value\":2}"), + new ProviderStreamPart.ToolInputEndPart(0), + new ProviderStreamPart.ToolInputEndPart(1), + new ProviderStreamPart.ChatResponsePart(multiToolCallResponse(List.of( + new AssistantMessage.ToolCall("call_1", "function", "legacyFirst", + "{\"value\":1}"), + new AssistantMessage.ToolCall("call_2", "function", "legacySecond", + "{\"value\":2}") + ), 2, 3)) + ), + Flux.just(new ProviderStreamPart.ChatResponsePart( + chatResponse("Done", "stop", 4, 5))) + ); + var events = new ArrayList(); + var first = recoveredLifecycleTool("first", events); + var second = recoveredLifecycleTool("second", events); + var request = GenerateTextRequest.builder() + .prompt("Use both") + .tools(List.of(first, second)) + .toolCallRepair(context -> { + var resolvedName = switch (context.getToolCall().getToolName()) { + case "legacyFirst" -> "first"; + case "legacySecond" -> "second"; + default -> throw new IllegalArgumentException("unexpected tool"); + }; + events.add("recovery:" + context.getToolCall().getToolCallId() + + ":" + resolvedName); + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName(resolvedName) + .input(context.getToolCall().getInput()) + .rawInput(context.getToolCall().getRawInput()) + .build())); + }) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + + StepVerifier.create(languageModel(chatModel, "openai").streamText(request) + .fullStream().collectList()) + .assertNext(parts -> { + var resolvedCalls = parts.stream() + .filter(part -> PartType.TOOL_CALL.equals(part.getType())) + .toList(); + assertThat(resolvedCalls).extracting(TextStreamPart::getToolCallId) + .containsExactly("call_1", "call_2"); + assertThat(resolvedCalls).extracting(TextStreamPart::getToolName) + .containsExactly("first", "second"); + assertThat(parts.stream() + .filter(part -> PartType.TOOL_RESULT.equals(part.getType())) + .toList()).extracting(TextStreamPart::getToolCallId) + .containsExactly("call_1", "call_2"); + }) + .verifyComplete(); + + assertThat(events).containsExactly( + "recovery:call_1:first", + "start:call_1:first", + "delta:call_1:{\"value\":1}", + "recovery:call_2:second", + "start:call_2:second", + "delta:call_2:{\"value\":2}", + "available:call_1:first", + "available:call_2:second", + "execute:call_1:first", + "execute:call_2:second" + ); + } + + private ToolDefinition recoveredLifecycleTool(String name, List events) { + return ToolDefinition.builder() + .name(name) + .inputSchema(Map.of("type", "object")) + .onInputStart(context -> Mono.fromRunnable(() -> events.add( + "start:" + context.getToolCallId() + ":" + context.getToolName()))) + .onInputDelta(context -> Mono.fromRunnable(() -> events.add( + "delta:" + context.getToolCallId() + ":" + context.getInputTextDelta()))) + .onInputAvailable(context -> Mono.fromRunnable(() -> events.add( + "available:" + context.getToolCallId() + ":" + context.getToolName()))) + .executor(context -> Mono.fromSupplier(() -> { + events.add("execute:" + context.getToolCallId() + ":" + context.getToolName()); + return Map.of("ok", true); + })) + .build(); + } + private ChatModel providerStreamingModel() { return mock(ChatModel.class, withSettings().extraInterfaces(ProviderStreamingChatModel.class)); diff --git a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolRepairTest.java b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolRepairTest.java index ca8bc192..69f86a85 100644 --- a/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolRepairTest.java +++ b/app/src/test/java/run/halo/aifoundation/service/language/LanguageModelToolRepairTest.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.ai.chat.messages.AssistantMessage; @@ -24,12 +25,243 @@ import run.halo.aifoundation.chat.StopCondition; import run.halo.aifoundation.part.PartType; import run.halo.aifoundation.tool.ToolCall; +import run.halo.aifoundation.tool.ToolCallFailureKind; import run.halo.aifoundation.tool.ToolCallRepairContext; import run.halo.aifoundation.tool.ToolCallRepairResult; import run.halo.aifoundation.tool.ToolDefinition; class LanguageModelToolRepairTest extends LanguageModelTestSupport { + @Test + void generateTextRecoversRenamedToolAndPreservesCallIdentity() { + var chatModel = mock(ChatModel.class); + when(chatModel.call(any(Prompt.class))).thenReturn( + toolCallResponse("call_1", "legacyWeather", "{\"location\":\"SF\"}", 2, 3), + chatResponse("It is 22C.", "stop", 4, 5) + ); + var executions = new AtomicInteger(); + var recoveryContext = new AtomicReference(); + var request = GenerateTextRequest.builder() + .prompt("Weather in SF?") + .context(Map.of("tenant", "demo")) + .tools(List.of(repairableWeatherTool(context -> { + executions.incrementAndGet(); + return Mono.just(Map.of("temperature", 22)); + }))) + .toolCallRepair(context -> { + recoveryContext.set(context); + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName("weather") + .input(context.getToolCall().getInput()) + .build())); + }) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + + StepVerifier.create(languageModel(chatModel, "openai").generateText(request)) + .assertNext(result -> { + assertThat(result.getText()).isEqualTo("It is 22C."); + assertThat(result.getToolErrors()).isEmpty(); + assertThat(result.getToolCalls()).singleElement().satisfies(call -> { + assertThat(call.getToolCallId()).isEqualTo("call_1"); + assertThat(call.getToolName()).isEqualTo("weather"); + }); + assertThat(result.getToolResults()).singleElement().satisfies(toolResult -> { + assertThat(toolResult.getToolCallId()).isEqualTo("call_1"); + assertThat(toolResult.getToolName()).isEqualTo("weather"); + }); + assertThat(result.getWarnings()).anySatisfy(warning -> { + assertThat(warning.getCode()).isEqualTo("tool-call-repaired"); + assertThat(warning.getProviderMetadata()) + .containsEntry("originalToolName", "legacyWeather") + .containsEntry("resolvedToolName", "weather"); + }); + }) + .verifyComplete(); + + assertThat(executions).hasValue(1); + assertThat(recoveryContext.get().getFailureKind()) + .isEqualTo(ToolCallFailureKind.UNKNOWN_TOOL); + assertThat(recoveryContext.get().getTool()).isNull(); + assertThat(recoveryContext.get().getAvailableTools()) + .extracting(ToolDefinition::getName) + .containsExactly("weather"); + assertThat(recoveryContext.get().getStepIndex()).isZero(); + assertThat(recoveryContext.get().getMessages()).isNotEmpty(); + assertThat(recoveryContext.get().getRequestContext()).containsEntry("tenant", "demo"); + + var secondPrompt = capturedPrompts(chatModel).get(1); + assertThat(secondPrompt.getInstructions().stream() + .filter(AssistantMessage.class::isInstance) + .map(AssistantMessage.class::cast) + .flatMap(message -> message.getToolCalls().stream())) + .singleElement() + .satisfies(call -> { + assertThat(call.id()).isEqualTo("call_1"); + assertThat(call.name()).isEqualTo("weather"); + }); + } + + @Test + void unknownToolWithoutAvailableTargetDoesNotInvokeRecovery() { + var chatModel = mock(ChatModel.class); + when(chatModel.call(any(Prompt.class))).thenReturn( + toolCallResponse("call_1", "missing", "{}", 2, 3), + chatResponse("Recovered after error.", "stop", 4, 5) + ); + var recoveries = new AtomicInteger(); + var request = GenerateTextRequest.builder() + .prompt("Use a tool") + .tools(List.of()) + .toolCallRepair(context -> { + recoveries.incrementAndGet(); + return Mono.just(ToolCallRepairResult.unrepaired()); + }) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + + StepVerifier.create(languageModel(chatModel, "openai").generateText(request)) + .assertNext(result -> { + assertThat(result.getToolErrors()).singleElement() + .satisfies(error -> assertThat(error.getErrorText()) + .isEqualTo("Unknown tool: missing")); + assertThat(result.getWarnings()).extracting("code") + .doesNotContain("tool-call-repair-failed"); + }) + .verifyComplete(); + assertThat(recoveries).hasValue(0); + } + + @Test + void unknownToolRejectsEveryUnsafeRecoveryShape() { + List>> recoveries = List.of( + context -> Mono.just(ToolCallRepairResult.unrepaired()), + context -> Mono.empty(), + context -> Mono.error(new IllegalStateException("recovery failed")), + context -> Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId("changed") + .toolName("weather") + .input(Map.of("location", "SF")) + .build())), + context -> Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId("call_1") + .toolName("unavailable") + .input(Map.of("location", "SF")) + .build())), + context -> Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId("call_1") + .toolName("weather") + .input(Map.of("city", "SF")) + .build())) + ); + + recoveries.forEach(recovery -> { + var chatModel = mock(ChatModel.class); + when(chatModel.call(any(Prompt.class))).thenReturn( + toolCallResponse("call_1", "legacyWeather", "{\"location\":\"SF\"}", 2, 3), + chatResponse("Recovered after error.", "stop", 4, 5) + ); + var executions = new AtomicInteger(); + var request = GenerateTextRequest.builder() + .prompt("Weather") + .tools(List.of(repairableWeatherTool(context -> { + executions.incrementAndGet(); + return Mono.just(Map.of()); + }))) + .toolCallRepair(recovery::apply) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + + var result = languageModel(chatModel, "openai").generateText(request).block(); + assertThat(result.getToolErrors()).singleElement() + .satisfies(error -> assertThat(error.getErrorText()) + .isEqualTo("Unknown tool: legacyWeather")); + assertThat(result.getWarnings()).extracting("code") + .contains("tool-call-repair-failed"); + assertThat(executions).hasValue(0); + }); + } + + @Test + void recoveredUnknownToolStillRequiresApprovalBeforeExecution() { + var chatModel = mock(ChatModel.class); + when(chatModel.call(any(Prompt.class))).thenReturn( + toolCallResponse("call_1", "legacyWeather", "{\"location\":\"SF\"}", 2, 3) + ); + var executions = new AtomicInteger(); + var request = GenerateTextRequest.builder() + .prompt("Weather") + .tools(List.of(ToolDefinition.builder() + .name("weather") + .inputSchema(weatherInputSchema()) + .needsApproval(true) + .executor(context -> { + executions.incrementAndGet(); + return Mono.just(Map.of()); + }) + .build())) + .toolCallRepair(context -> Mono.just(ToolCallRepairResult.repaired( + ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName("weather") + .input(context.getToolCall().getInput()) + .build()))) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + + StepVerifier.create(languageModel(chatModel, "openai").generateText(request)) + .assertNext(result -> { + assertThat(result.getToolApprovalRequests()).singleElement() + .satisfies(approval -> { + assertThat(approval.getToolCallId()).isEqualTo("call_1"); + assertThat(approval.getToolName()).isEqualTo("weather"); + }); + assertThat(result.getToolResults()).isEmpty(); + assertThat(result.getWarnings()).extracting("code") + .contains("tool-call-repaired"); + }) + .verifyComplete(); + assertThat(executions).hasValue(0); + verify(chatModel, times(1)).call(any(Prompt.class)); + } + + @Test + void recoveredUnknownExternalToolIsHandedOffWithoutExecution() { + var chatModel = mock(ChatModel.class); + when(chatModel.call(any(Prompt.class))).thenReturn( + toolCallResponse("call_1", "legacyWeather", "{\"location\":\"SF\"}", 2, 3) + ); + var request = GenerateTextRequest.builder() + .prompt("Weather") + .tools(List.of(ToolDefinition.builder() + .name("weather") + .inputSchema(weatherInputSchema()) + .build())) + .toolCallRepair(context -> Mono.just(ToolCallRepairResult.repaired( + ToolCall.builder() + .toolCallId(context.getToolCall().getToolCallId()) + .toolName("weather") + .input(context.getToolCall().getInput()) + .build()))) + .stopWhen(StopCondition.stepCountIs(2)) + .build(); + + StepVerifier.create(languageModel(chatModel, "openai").generateText(request)) + .assertNext(result -> { + assertThat(result.getToolCalls()).singleElement().satisfies(call -> { + assertThat(call.getToolCallId()).isEqualTo("call_1"); + assertThat(call.getToolName()).isEqualTo("weather"); + }); + assertThat(result.getToolResults()).isEmpty(); + assertThat(result.getToolErrors()).isEmpty(); + assertThat(result.getWarnings()).extracting("code") + .contains("tool-call-repaired", "external-tool-pending"); + }) + .verifyComplete(); + verify(chatModel, times(1)).call(any(Prompt.class)); + } + @Test void generateTextReportsMalformedJsonBeforeSchemaValidationAndPreservesPairing() { var chatModel = mock(ChatModel.class); diff --git a/app/src/test/java/run/halo/aifoundation/ui/UIMessageChatHandlerTest.java b/app/src/test/java/run/halo/aifoundation/ui/UIMessageChatHandlerTest.java index 66622a8b..3cb646c2 100644 --- a/app/src/test/java/run/halo/aifoundation/ui/UIMessageChatHandlerTest.java +++ b/app/src/test/java/run/halo/aifoundation/ui/UIMessageChatHandlerTest.java @@ -10,6 +10,10 @@ import java.util.concurrent.atomic.AtomicReference; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import run.halo.aifoundation.agent.Agent; +import run.halo.aifoundation.agent.AgentCallException; +import run.halo.aifoundation.agent.AgentCallPhase; +import run.halo.aifoundation.agent.AgentOptions; import run.halo.aifoundation.control.CancellationSource; import run.halo.aifoundation.chat.GenerateTextRequest; import run.halo.aifoundation.chat.GenerateTextResult; @@ -21,6 +25,7 @@ import run.halo.aifoundation.message.ModelMessage; import run.halo.aifoundation.part.GenerationContentPart; import run.halo.aifoundation.part.TextStreamPart; +import run.halo.aifoundation.schema.OutputSpec; import run.halo.aifoundation.tool.ToolCall; import run.halo.aifoundation.tool.ToolResult; import org.junit.jupiter.api.Test; @@ -30,6 +35,220 @@ class UIMessageChatHandlerTest { record Metadata(String chatId) { } + record AgentProfile(String instructions) { + } + + @Test + void typedAgentSubmitReceivesConvertedMessagesAndOperationalControls() { + var model = new FakeLanguageModel(List.of( + TextStreamPart.start("assistant-agent"), + TextStreamPart.textDelta("text-1", "agent answer"), + TextStreamPart.finish(null, null, null) + )); + var preparations = new AtomicInteger(); + var middleware = new LanguageModelMiddleware() { + }; + var cancellation = new CancellationSource(); + var agent = Agent.create(AgentOptions.forModel(model, AgentProfile.class) + .instructions("base") + .callValidator(options -> { + if (options == null || options.instructions().isBlank()) { + throw new IllegalArgumentException("instructions required"); + } + }) + .prepareCall(context -> { + preparations.incrementAndGet(); + assertThat(context.getCall().getMessages()).singleElement() + .satisfies(message -> assertThat(message.getContent().getFirst().getText()) + .isEqualTo("Hi")); + assertThat(context.getOptions()).isEqualTo(new AgentProfile("prepared")); + context.getRequestBuilder().system(context.getOptions().instructions()); + return Mono.just(context.prepared()); + }) + .build()); + var request = new UIMessageChatRequest<>("chat-1", List.of( + new UIMessage<>("user", UIMessageRole.USER, + List.of(UIMessageParts.text("text", "Hi")), new Metadata("chat")) + ), UIMessageChatTrigger.SUBMIT_MESSAGE, null); + + var chat = UIMessageChatHandlers.streamAgent(agent, request, + new AgentProfile("prepared"), options -> options + .request(builder -> builder + .metadata(Map.of("endpoint", "agent")) + .context(Map.of("tenant", "demo")) + .headers(Map.of("trace", "trace-1"))) + .cancellationToken(cancellation.token()) + .middleware(middleware)); + + chat.response().stream().collectList().block(); + var finish = chat.finish().block(); + + assertThat(preparations).hasValue(1); + assertThat(model.streamTextCalls).hasValue(1); + assertThat(model.capturedRequest.getSystem()).isEqualTo("prepared"); + assertThat(model.capturedRequest.getMetadata()).containsEntry("endpoint", "agent"); + assertThat(model.capturedRequest.getContext()).containsEntry("tenant", "demo"); + assertThat(model.capturedRequest.getHeaders()).containsEntry("trace", "trace-1"); + assertThat(model.capturedRequest.getMiddleware()).containsExactly(middleware); + assertThat(model.capturedRequest.getCancellationToken()).isSameAs(cancellation.token()); + assertThat(finish.responseMessage().text()).isEqualTo("agent answer"); + assertThat(finish.messages()).hasSize(2); + } + + @Test + void typedAgentRegenerateUsesExistingTriggerSemantics() { + var model = new FakeLanguageModel(List.of( + TextStreamPart.textDelta("text-1", "new answer"), + TextStreamPart.finish(null, null, null) + )); + var agent = Agent.create(model, "answer"); + var user = new UIMessage<>("user-1", UIMessageRole.USER, + List.of(UIMessageParts.text("question", "Question")), new Metadata("chat")); + var oldAssistant = new UIMessage<>("assistant-1", UIMessageRole.ASSISTANT, + List.of(UIMessageParts.text("old", "Old answer")), new Metadata("chat")); + var laterUser = new UIMessage<>("user-2", UIMessageRole.USER, + List.of(UIMessageParts.text("later", "Later")), new Metadata("chat")); + var request = new UIMessageChatRequest<>("chat-1", + List.of(user, oldAssistant, laterUser), UIMessageChatTrigger.REGENERATE_MESSAGE, + "assistant-1"); + + var chat = UIMessageChatHandlers.streamAgent(agent, request, null); + chat.response().stream().collectList().block(); + + assertThat(model.capturedRequest.getMessages()).singleElement() + .satisfies(message -> assertThat(message.getContent().getFirst().getText()) + .isEqualTo("Question")); + assertThat(chat.validation().messages()).containsExactly(user); + assertThat(chat.finish().block().responseMessage().text()).isEqualTo("new answer"); + } + + @Test + void handlerRejectsMissingOrConflictingExecutionAndAgentPolicyOverrides() { + var model = new FakeLanguageModel(List.of()); + var agent = Agent.create(model, "agent policy"); + var messages = List.of(new UIMessage<>("user", UIMessageRole.USER, + List.of(UIMessageParts.text("text", "Hi")), new Metadata("chat"))); + + assertThatThrownBy(() -> UIMessageChatHandlers.streamText(options -> + options.messages(messages))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one"); + assertThatThrownBy(() -> UIMessageChatHandlers.streamText(options -> options + .model(model) + .agent(agent) + .messages(messages))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exactly one"); + assertThatThrownBy(() -> UIMessageChatHandlers.streamText(options -> options + .agent(agent) + .messages(messages) + .request(builder -> builder.system("transport override")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must not replace agent policy"); + assertThatThrownBy(() -> UIMessageChatHandlers.streamText(options -> options + .agent(agent) + .messages(messages) + .prepare(context -> Mono.empty()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unavailable for agent execution"); + assertThat(model.streamTextCalls).hasValue(0); + } + + @Test + void agentValidationAndPreparationFailuresUseExistingTerminalErrorFlow() { + var model = new FakeLanguageModel(List.of()); + var validationAgent = Agent.create(AgentOptions.forModel(model, String.class) + .callValidator(value -> { + throw new IllegalArgumentException("profile rejected"); + }) + .build()); + var preparationAgent = Agent.create(AgentOptions.forModel(model) + .prepareCall(context -> Mono.error(new IllegalStateException("prepare failed"))) + .build()); + var messages = List.of(new UIMessage<>("user", UIMessageRole.USER, + List.of(UIMessageParts.text("text", "Hi")), new Metadata("chat"))); + + var validation = UIMessageChatHandlers.streamText(options -> options + .agent(validationAgent, "invalid") + .messages(messages) + .onError(error -> ((AgentCallException) error).getPhase().name())); + var preparation = UIMessageChatHandlers.streamText(options -> options + .agent(preparationAgent) + .messages(messages) + .onError(error -> ((AgentCallException) error).getPhase().name())); + + assertThat(validation.response().stream().collectList().block()).singleElement() + .isEqualTo(UIMessageChunks.error(AgentCallPhase.VALIDATION.name())); + assertThat(preparation.response().stream().collectList().block()).singleElement() + .isEqualTo(UIMessageChunks.error(AgentCallPhase.PREPARATION.name())); + assertThat(model.streamTextCalls).hasValue(0); + } + + @Test + void agentUsesCanonicalMultiStepToolWireAndFinalizesRecoveredName() { + var model = new FakeLanguageModel(List.of( + TextStreamPart.start("assistant-agent"), + TextStreamPart.startStep(0), + TextStreamPart.toolInputStart("input-1", "call-1", "legacyWeather"), + TextStreamPart.toolInputDelta("input-1", "call-1", "legacyWeather", + "{\"location\":\"SF\"}"), + TextStreamPart.toolInputEnd("input-1", "call-1", "legacyWeather"), + TextStreamPart.toolCall(ToolCall.builder() + .toolCallId("call-1") + .toolName("weather") + .input(Map.of("location", "SF")) + .build()), + TextStreamPart.toolResult(ToolResult.builder() + .toolCallId("call-1") + .toolName("weather") + .result(Map.of("temperature", 22)) + .build()), + TextStreamPart.finishStep(0, null, null, null, List.of(), null, null, Map.of()), + TextStreamPart.startStep(1), + TextStreamPart.textDelta("text-1", "{\"answer\":\"22C\"}"), + TextStreamPart.finishStep(1, null, null, null, List.of(), null, null, Map.of()), + TextStreamPart.finish(null, null, null) + )); + var agent = Agent.create(AgentOptions.forModel(model) + .instructions("structured agent") + .output(OutputSpec.json()) + .build()); + var messages = List.of(new UIMessage<>("user", UIMessageRole.USER, + List.of(UIMessageParts.text("text", "Weather?")), new Metadata("chat"))); + + var chat = UIMessageChatHandlers.streamText(options -> options + .agent(agent) + .messages(messages)); + var chunks = chat.response().stream().collectList().block(); + var finish = chat.finish().block(); + + assertThat(chunks).extracting(UIMessageChunk::type) + .containsExactly( + UIMessageChunkType.START, + UIMessageChunkType.START_STEP, + UIMessageChunkType.TOOL_INPUT_START, + UIMessageChunkType.TOOL_INPUT_DELTA, + UIMessageChunkType.TOOL_INPUT_AVAILABLE, + UIMessageChunkType.TOOL_OUTPUT_AVAILABLE, + UIMessageChunkType.FINISH_STEP, + UIMessageChunkType.START_STEP, + UIMessageChunkType.TEXT_DELTA, + UIMessageChunkType.FINISH_STEP, + UIMessageChunkType.FINISH + ); + assertThat(finish.responseMessage().parts()) + .filteredOn(ToolPart.class::isInstance) + .singleElement() + .satisfies(part -> { + var tool = (ToolPart) part; + assertThat(tool.toolCallId()).isEqualTo("call-1"); + assertThat(tool.toolName()).isEqualTo("weather"); + assertThat(tool.state()).isEqualTo(ToolPartState.OUTPUT_AVAILABLE); + }); + assertThat(model.capturedRequest.getOutput().getType()) + .isEqualTo(OutputSpec.json().getType()); + } + @Test void streamsModelOutputAndExposesResponseFinishValidationAndConversion() { var model = new FakeLanguageModel(List.of( diff --git a/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java b/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java index a8a0fbda..f851791b 100644 --- a/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java +++ b/app/src/test/java/run/halo/aifoundation/ui/UIMessageStreamReaderTest.java @@ -209,6 +209,24 @@ void readerReducesCanonicalToolChunksToDynamicToolParts() { ); } + @Test + void readerFinalizesRecoveredToolNameOnExistingCallIdentity() { + var result = UIMessageStreamReader.read(new UIMessageStream(Flux.just( + UIMessageChunks.toolInputStart("call-1", "legacyWeather"), + UIMessageChunks.toolInputDelta("call-1", "{\"location\":\"SF\"}"), + UIMessageChunks.toolInputAvailable("call-1", "weather", + Map.of("location", "SF"), Map.of("recovered", true)), + UIMessageChunks.toolOutputAvailable("call-1", "weather", + Map.of("temperature", 22), Map.of()) + ))); + + assertThat(result.responseMessage().block().parts()).containsExactly( + UIMessageParts.tool("call-1", "weather", ToolPartState.OUTPUT_AVAILABLE, + Map.of("location", "SF"), null, Map.of("temperature", 22), null, null, + Map.of("recovered", true)) + ); + } + @Test void readerHonorsIdPriorityMetadataSupplierAndImmutableSnapshots() { var metadataCalls = new AtomicInteger(); diff --git a/dev/en/README.md b/dev/en/README.md index 9f4ea3f5..cb396e45 100644 --- a/dev/en/README.md +++ b/dev/en/README.md @@ -8,28 +8,29 @@ selector using the repository's current public contracts. ## Start by task -| Goal | Guide | -| ------------------------------------------- | ---------------------------------------------------------------------------- | -| Call a language model from a plugin | [SDK Core: Getting started](./sdk-core/getting-started.md) | -| Generate or stream text | [Generating text](./sdk-core/generating-text.md) | -| Generate typed JSON | [Structured output](./sdk-core/generating-structured-data.md) | -| Use server/client tools, steps, or approval | [Tools](./sdk-core/tools-and-tool-calling.md) | -| Build embedding, reranking, or RAG flows | [Embeddings, reranking, and RAG](./sdk-core/embeddings-reranking-and-rag.md) | -| Generate or edit images | [Image generation](./sdk-core/image-generation.md) | -| Build a Vue chat interface | [SDK UI: Chatbot](./sdk-ui/chatbot.md) | -| Persist messages | [Message persistence](./sdk-ui/chatbot-message-persistence.md) | -| Execute browser tools or approvals | [Tool interaction](./sdk-ui/chatbot-tool-usage.md) | -| Customize requests or read streams directly | [Transport and stream reading](./sdk-ui/transport-and-reading-streams.md) | -| Select a model in plugin settings | [FormKit model selector](./model-selector.md) | -| Build an end-to-end consumer plugin | [Plugin integration example](./plugin-integration-examples.md) | -| Look up a Java type | [SDK Core API index](./sdk-core/api-reference.md) | -| Look up an npm export | [SDK UI export index](./sdk-ui/api-reference.md) | +| Goal | Guide | +| ---------------------------------------------- | ---------------------------------------------------------------------------- | +| Call a language model from a plugin | [SDK Core: Getting started](./sdk-core/getting-started.md) | +| Generate or stream text | [Generating text](./sdk-core/generating-text.md) | +| Generate typed JSON | [Structured output](./sdk-core/generating-structured-data.md) | +| Use server/client tools, steps, or approval | [Tools](./sdk-core/tools-and-tool-calling.md) | +| Build a reusable agent and UI Message endpoint | [Agent runtime](./sdk-core/agents.md) | +| Build embedding, reranking, or RAG flows | [Embeddings, reranking, and RAG](./sdk-core/embeddings-reranking-and-rag.md) | +| Generate or edit images | [Image generation](./sdk-core/image-generation.md) | +| Build a Vue chat interface | [SDK UI: Chatbot](./sdk-ui/chatbot.md) | +| Persist messages | [Message persistence](./sdk-ui/chatbot-message-persistence.md) | +| Execute browser tools or approvals | [Tool interaction](./sdk-ui/chatbot-tool-usage.md) | +| Customize requests or read streams directly | [Transport and stream reading](./sdk-ui/transport-and-reading-streams.md) | +| Select a model in plugin settings | [FormKit model selector](./model-selector.md) | +| Build an end-to-end consumer plugin | [Plugin integration example](./plugin-integration-examples.md) | +| Look up a Java type | [SDK Core API index](./sdk-core/api-reference.md) | +| Look up an npm export | [SDK UI export index](./sdk-ui/api-reference.md) | ## SDK surfaces [SDK Core](./sdk-core/README.md) resolves language, embedding, reranking, and image models and provides text generation, multimodal input, structured output, tools, RAG, middleware, -cancellation, lifecycle, and the Java UI Message bridge. +cancellation, lifecycle, immutable agents, and the Java UI Message bridge. [SDK UI](./sdk-ui/README.md) provides Vue and framework-neutral chat state, transports, stream reduction, tools, files, runtime schemas, persistence helpers, completion, and incremental object diff --git a/dev/en/dev.md b/dev/en/dev.md index e22e9a2a..a4aae2a7 100644 --- a/dev/en/dev.md +++ b/dev/en/dev.md @@ -21,6 +21,7 @@ public Java type. | Text, messages, media, reasoning, streaming, result metadata | [Generating text](./sdk-core/generating-text.md) | | Objects, arrays, choices, partial and final validation | [Structured output](./sdk-core/generating-structured-data.md) | | Server and external tools, steps, approval, repair, input deltas | [Tools](./sdk-core/tools-and-tool-calling.md) | +| Immutable agents, typed call preparation, bounded steps, UI Message | [Agent runtime](./sdk-core/agents.md) | | Embedding batches, similarity, reranking, RAG middleware, sources | [Embeddings, reranking, and RAG](./sdk-core/embeddings-reranking-and-rag.md) | | Text-to-image, image editing, masks, middleware, generated files | [Image generation](./sdk-core/image-generation.md) | | Middleware, lifecycle, cancellation, timeout, retry | [Middleware and lifecycle](./sdk-core/middleware-and-lifecycle.md) | @@ -37,5 +38,7 @@ public Java type. - Treat capability data, warnings, and provider metadata as runtime data. - Keep authorization, persistence, vector storage, file lifecycle, and business policy in the consumer plugin. +- Treat an agent as one-call orchestration; durable runs, resume, scheduling, and memory remain + consumer responsibilities. See the [complete plugin integration example](./plugin-integration-examples.md). diff --git a/dev/en/sdk-core/README.md b/dev/en/sdk-core/README.md index 0e9184d8..74f64d5e 100644 --- a/dev/en/sdk-core/README.md +++ b/dev/en/sdk-core/README.md @@ -14,26 +14,28 @@ parameter mappings. 2. [Generating and streaming text](./generating-text.md) 3. [Structured output](./generating-structured-data.md) 4. [Tools and multi-step generation](./tools-and-tool-calling.md) -5. [Embeddings, reranking, and RAG](./embeddings-reranking-and-rag.md) -6. [Image generation](./image-generation.md) -7. [Middleware, step control, and lifecycle](./middleware-and-lifecycle.md) -8. [Error handling](./error-handling.md) -9. [Complete public API index](./api-reference.md) +5. [Agent runtime](./agents.md) +6. [Embeddings, reranking, and RAG](./embeddings-reranking-and-rag.md) +7. [Image generation](./image-generation.md) +8. [Middleware, step control, and lifecycle](./middleware-and-lifecycle.md) +9. [Error handling](./error-handling.md) +10. [Complete public API index](./api-reference.md) ## Entry points -| Need | Main types | -| --------------------- | --------------------------------------------------------------- | -| Resolve a model | `AiModelService` | -| Generate text | `LanguageModel`, `GenerateTextRequest`, `GenerateTextResult` | -| Stream text | `StreamTextResult`, `TextStreamPart` | -| Structured output | `OutputSpec`, `JsonSchema`, `StructuredSchema` | -| Tools | `ToolDefinition`, `ToolChoice`, `StopCondition`, `PreparedStep` | -| Embeddings | `EmbeddingModel`, `EmbeddingRequest`, `EmbeddingUtils` | -| Reranking and RAG | `RerankingModel`, `RagMiddlewares`, `RagMiddlewareOptions` | -| Images | `ImageGenerationModel`, `GenerateImageRequest`, `GeneratedFile` | -| Request control | `CancellationSource`, `GenerationTimeouts`, lifecycle APIs | -| Browser stream bridge | `UIMessageChatHandlers`, `UIMessageStreamResponse` | +| Need | Main types | +| --------------------- | ----------------------------------------------------------------- | +| Resolve a model | `AiModelService` | +| Generate text | `LanguageModel`, `GenerateTextRequest`, `GenerateTextResult` | +| Stream text | `StreamTextResult`, `TextStreamPart` | +| Structured output | `OutputSpec`, `JsonSchema`, `StructuredSchema` | +| Tools | `ToolDefinition`, `ToolChoice`, `StopCondition`, `PreparedStep` | +| Agent runtime | `Agent`, `AgentOptions`, `AgentCall`, `PreparedAgentCall` | +| Embeddings | `EmbeddingModel`, `EmbeddingRequest`, `EmbeddingUtils` | +| Reranking and RAG | `RerankingModel`, `RagMiddlewares`, `RagMiddlewareOptions` | +| Images | `ImageGenerationModel`, `GenerateImageRequest`, `GeneratedFile` | +| Request control | `CancellationSource`, `GenerationTimeouts`, lifecycle APIs | +| Browser stream bridge | `UIMessageChatHandlers`, `UIMessageStreamResponse`, `streamAgent` | The API is reactive: completed values normally use `Mono` and event streams use `Flux`. Preserve `responseMessages` when continuing a conversation or tool loop, and treat model diff --git a/dev/en/sdk-core/agents.md b/dev/en/sdk-core/agents.md new file mode 100644 index 00000000..b86f37af --- /dev/null +++ b/dev/en/sdk-core/agents.md @@ -0,0 +1,209 @@ +# Agent runtime + +[简体中文](../../zh-CN/sdk-core/agents.md) | English + +`run.halo.aifoundation.agent` provides reusable, immutable, provider-neutral agent definitions. +Each invocation composes a fresh `GenerateTextRequest` and delegates it to the existing +`LanguageModel`. The same model runtime remains authoritative for tool loops, structured output, +approval, external tools, middleware, lifecycle, cancellation, and result aggregation. + +## Resolve a model and create a definition + +```java +record AnswerOptions(String style) { +} + +Mono> createAgent(AiModelService service, String modelName) { + return service.languageModel(modelName) + .map(model -> Agent.create(AgentOptions.forModel(model, AnswerOptions.class) + .id("site-assistant") + .instructions("Answer questions about this site.") + .maxOutputTokens(1024) + .tools(List.of(searchTool(), handoffTool())) + .callValidator(options -> { + if (options == null || options.style() == null) { + throw new IllegalArgumentException("style is required"); + } + }) + .prepareCall(context -> { + var suffix = switch (context.getOptions().style()) { + case "brief" -> " Keep the final answer brief."; + case "detailed" -> " Explain the answer in detail."; + default -> throw new IllegalArgumentException("unsupported style"); + }; + context.getRequestBuilder().system( + "Answer questions about this site." + suffix); + return Mono.just(context.prepared()); + }) + .build())); +} +``` + +Definitions defensively copy collections and maps and can be reused as beans. Do not mutate tools, +metadata, or request builders between calls. An agent without an explicit `stopWhen` permits at +most 20 model steps and still ends early when no executable continuation exists. Direct +`LanguageModel` defaults are unchanged. + +Composition order is definition defaults, call input and operational controls, definition and call +middleware/lifecycle, one asynchronous `prepareCall`, and then `prepareStep` for each model step. +Call preparation returns `PreparedAgentCall` and may replace the model or change policy for that +invocation only. + +## Calls and results + +```java +AgentCall call = AgentCall.builder() + .prompt("Summarize the latest article") + .options(new AnswerOptions("brief")) + .metadata(Map.of("operation", "summary")) + .context(Map.of("postName", "hello-halo")) + .cancellationToken(cancellation.token()) + .build(); + +Mono generated = agent.generate(call); +StreamTextResult streamed = agent.stream(call); + +Flux text = streamed.textStream(); +Mono finalResult = streamed.result(); +Mono> responseMessages = streamed.responseMessages(); +``` + +`generate` and `stream` return the existing result types. All projections of one streaming result +share one preparation and one provider execution. Reading `textStream()`, `fullStream()`, and +`result()` together does not repeat tools or preparation side effects. Preserve `responseMessages` +when continuing a conversation. + +Put structured output policy on the definition. Read the validated value from +`GenerateTextResult.getOutput()` or `StreamTextResult.output()` rather than reparsing assistant +text. + +## Step policy and lifecycle + +```java +Agent agent = Agent.create(AgentOptions + .forModel(model, AnswerOptions.class) + .instructions("Plan, then answer.") + .tools(List.of(planTool, answerTool)) + .prepareStep(context -> context.getStepIndex() == 0 + ? PreparedStep.builder().activeTools(List.of("plan")).build() + : PreparedStep.builder().activeTools(List.of("answer")).build()) + .stopWhen(StopCondition.stepCountIs(6)) + .lifecycle(List.of(lifecycle)) + .build()); +``` + +`prepareStep` affects one model step; call preparation runs once. Observe request, step, tool, +approval, and terminal events with `GenerationLifecycle`. Lifecycle callbacks are diagnostics, not +business results, and callback failures become warnings. + +When definition-level `activeTools` is unset, every defined tool remains available; an explicit +empty list disables all tools. A non-null `activeTools` returned by `prepareStep` takes precedence, +while returning `null` leaves the definition policy unchanged for that step. + +## Complete tool lifecycle + +A server tool has an `executor`. A tool without an executor is completed by the caller. Approval +uses `needsApproval(true)` or a dynamic `approvalPredicate`: + +```java +ToolDefinition publish = ToolDefinition.builder() + .name("publish") + .description("Publish a reviewed draft") + .inputSchema(Map.of( + "type", "object", + "properties", Map.of("postName", Map.of("type", "string")), + "required", List.of("postName") + )) + .needsApproval(true) + .executor(context -> publishingService.publish( + String.valueOf(context.getInput().get("postName")))) + .build(); + +ToolDefinition browserLookup = ToolDefinition.builder() + .name("browser_lookup") + .description("Look up data in the caller's browser") + .inputSchema(Map.of("type", "object")) + .build(); // no executor: external tool +``` + +Submit approval decisions and external results or errors as continued UI or model-message history, +preserving the original `toolCallId`. Authorization, idempotency, audit, and side-effect safety +belong to the consumer's tool implementation. + +## Invalid input and renamed-tool recovery + +`ToolCallRepairCallback` receives two explicit failure kinds: + +- `INVALID_INPUT`: the tool exists but its input fails schema validation; `getTool()` is present. +- `UNKNOWN_TOOL`: the name is absent from the current tool set; `getTool()` is null and + `getAvailableTools()` lists valid targets. + +```java +.toolCallRepair(context -> { + var original = context.getToolCall(); + if (context.getFailureKind() == ToolCallFailureKind.UNKNOWN_TOOL + && original.getToolName().equals("legacy_search")) { + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(original.getToolCallId()) + .toolName("search") + .input(original.getInput()) + .build())); + } + if (context.getFailureKind() == ToolCallFailureKind.INVALID_INPUT + && original.getToolName().equals("search")) { + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(original.getToolCallId()) + .toolName(original.getToolName()) + .input(Map.of("query", String.valueOf(original.getInput().get("q")))) + .build())); + } + return Mono.just(ToolCallRepairResult.unrepaired()); +}) +``` + +Recovery must retain the call id. Known-tool input repair cannot rename the tool; unknown-tool +recovery can select only a currently available tool. The runtime revalidates existence, input +schema, approval, and execution policy. A missing or throwing callback, changed id, unavailable +name, or still-invalid input never executes and produces the safe original error plus a stable +warning. Executor failures, denied approval, output-schema failures, cancellation, and timeouts are +not recovery inputs. + +## Java UI Message endpoint and browser continuation + +```java +UIMessageChatResult chat = UIMessageChatHandlers.streamAgent( + agent, + chatRequest, + new AnswerOptions("brief"), + options -> options + .metadataSupplier(MyMetadata::empty) + .serializer(json::writeValueAsString) + .cancellationToken(cancellation.token()) + .onFinish(finish -> conversations.save(finish.messages())) +); + +UIMessageStreamResponse response = chat.response(); +``` + +The authenticated endpoint derives typed call options. Transport input cannot replace agent +instructions, tools, output, or stop policy. `streamAgent` shares validation, conversion, +regeneration, reasoning history, cancellation, aggregation, and wire chunks with direct-model +execution. + +The browser continues to use the existing `Chat` or `useChat`, `DefaultChatTransport`, approval, +`addToolOutput`, and `addToolApprovalResponse` APIs. There is no agent-specific transport. Persist +final reduced UI messages, validate restored data with public schemas, and bound automatic +continuation with an explicit predicate and step limit. + +## Runtime boundary + +The agent owns policy composition and one invocation. The consumer still owns: + +- durable conversations, UI messages, runs, checkpoints, and business state; +- resume after restart, background scheduling, retry queues, and distributed coordination; +- long-term memory, retrieval indexes, authorization, quotas, rate limits, and audit; +- browser, editor, publishing, network, and other business tools and their side effects. + +An in-memory `Agent` cannot resume an unfinished run after a process restart. A durable consumer +must store messages, tool state, and business checkpoints, then start a new agent invocation to +continue. diff --git a/dev/en/sdk-core/api-reference.md b/dev/en/sdk-core/api-reference.md index 679db169..2541991d 100644 --- a/dev/en/sdk-core/api-reference.md +++ b/dev/en/sdk-core/api-reference.md @@ -21,6 +21,23 @@ not consumer APIs. `imageGenerationTextToImage()` cover common requirements. The same capability model is used by the [FormKit model selector](../model-selector.md). +## Agent runtime + +| Public type | Purpose | +| -------------------------------------- | ------------------------------------------------------------------------------------ | +| `Agent` | Immutable reusable typed definition with `generate` and `stream`. | +| `AgentOptions` | Model, instructions, tools, output, steps, recovery, sampling, and default controls. | +| `AgentCall` | One prompt or message call with typed options and operational controls. | +| `AgentCallValidator` | Validate endpoint-owned typed options before provider execution. | +| `AgentCallPrepare` | Asynchronously prepare one call and optionally replace its model. | +| `AgentCallPrepareContext` | Current call, options, base model, and fresh request builder. | +| `PreparedAgentCall` | Effective model and request after call preparation. | +| `AgentCallPhase`, `AgentCallException` | Validation- and preparation-phase failure reporting. | + +An agent without an explicit stop condition permits at most 20 steps; direct `LanguageModel` +defaults are unchanged. See [Agent runtime](./agents.md) for construction, recovery, UI Message, +and persistence boundaries. + ## Text generation | Area | Public types | @@ -92,15 +109,15 @@ package that matches model input, generation output, or persisted UI state. In p ## Tools -| Area | Public types | -| ------------------------ | -------------------------------------------------------------------------------------------- | -| Definition and selection | `ToolDefinition`, `ToolChoice`, `ToolExecutor`, `ToolExecutionContext` | -| Results | `ToolCall`, `ToolResult`, `ToolError`, `ToolInputParseError` | -| Approval | `ToolApprovalPolicy`, `ToolApprovalPredicate`, `ToolApprovalRequest`, `ToolApprovalResponse` | -| Repair | `ToolCallRepairCallback`, `ToolCallRepairContext`, `ToolCallRepairResult` | -| Input start | `ToolInputStartCallback`, `ToolInputStartContext` | -| Input delta | `ToolInputDeltaCallback`, `ToolInputDeltaContext` | -| Input available | `ToolInputAvailableCallback`, `ToolInputAvailableContext` | +| Area | Public types | +| ------------------------ | ------------------------------------------------------------------------------------------------ | +| Definition and selection | `ToolDefinition`, `ToolChoice`, `ToolExecutor`, `ToolExecutionContext` | +| Results | `ToolCall`, `ToolResult`, `ToolError`, `ToolInputParseError` | +| Approval | `ToolApprovalPolicy`, `ToolApprovalPredicate`, `ToolApprovalRequest`, `ToolApprovalResponse` | +| Repair | `ToolCallFailureKind`, `ToolCallRepairCallback`, `ToolCallRepairContext`, `ToolCallRepairResult` | +| Input start | `ToolInputStartCallback`, `ToolInputStartContext` | +| Input delta | `ToolInputDeltaCallback`, `ToolInputDeltaContext` | +| Input available | `ToolInputAvailableCallback`, `ToolInputAvailableContext` | The main `ToolDefinition` builder fields are `name`, `description`, `inputSchema`, `executor`, `strict`, `inputExamples`, `approvalPolicy`, and `approvalPredicate`. @@ -161,10 +178,13 @@ All SDK business exceptions derive from `AiFoundationException`. | Preparation | `UIMessageChatPrepare`, `UIMessageChatPrepareContext` | | Cancellation | `UIMessageCancellation`, `UIMessageCancellations` | -`UIMessageChatOptions` configures the model, messages, chat request, response message, metadata, +`UIMessageChatOptions` configures exactly one model or typed agent, messages, chat request, response message, metadata, message IDs, serializer, request builder, preparation, middleware, validation, conversion, finish/error callbacks, cancellation, and read-error propagation. +Use the typed `UIMessageChatHandlers.streamAgent(...)` entry point for agent endpoints. Transport +input cannot replace agent semantic policy; see [Agent runtime](./agents.md). + ### Validation and conversion | Area | Public types | diff --git a/dev/zh-CN/README.md b/dev/zh-CN/README.md index c9fddbf3..d6352dd4 100644 --- a/dev/zh-CN/README.md +++ b/dev/zh-CN/README.md @@ -7,23 +7,24 @@ Java 后端的 SDK Core 与浏览器 / Vue 的 SDK UI,示例均以仓库公开 ## 从哪里开始 -| 目标 | 起点 | -| ------------------------------ | -------------------------------------------------------------------------------- | -| 在插件后端调用语言模型 | [SDK Core:快速开始](./sdk-core/getting-started.md) | -| 生成或流式返回文本 | [SDK Core:生成与流式文本](./sdk-core/generating-text.md) | -| 使用结构化输出 | [SDK Core:生成结构化数据](./sdk-core/generating-structured-data.md) | -| 使用服务端工具、多步骤或审批 | [SDK Core:工具调用](./sdk-core/tools-and-tool-calling.md) | -| 生成向量、重排或组合 RAG | [SDK Core:Embedding、Rerank 与 RAG](./sdk-core/embeddings-reranking-and-rag.md) | -| 生成或编辑图片 | [SDK Core:图像生成](./sdk-core/image-generation.md) | -| 在 Vue 中构建聊天界面 | [SDK UI:Chatbot](./sdk-ui/chatbot.md) | -| 保存、恢复和校验聊天消息 | [SDK UI:消息持久化](./sdk-ui/chatbot-message-persistence.md) | -| 在前端执行工具或处理审批 | [SDK UI:工具交互](./sdk-ui/chatbot-tool-usage.md) | -| 在插件设置中选择模型 | [FormKit:模型选择器](./model-selector.md) | -| 自定义请求、Transport 或读取流 | [SDK UI:Transport 与读取消息流](./sdk-ui/transport-and-reading-streams.md) | -| 查询 SSE wire 格式 | [SDK UI:Stream Protocol](./sdk-ui/stream-protocol.md) | -| 按 Java 类型名查询完整 API | [SDK Core:公开 API 索引](./sdk-core/api-reference.md) | -| 按 npm 导出名查询完整 API | [SDK UI:公开导出索引](./sdk-ui/api-reference.md) | -| 在 Halo 插件中完成端到端集成 | [插件集成示例](./plugin-integration-examples.md) | +| 目标 | 起点 | +| ----------------------------------- | -------------------------------------------------------------------------------- | +| 在插件后端调用语言模型 | [SDK Core:快速开始](./sdk-core/getting-started.md) | +| 生成或流式返回文本 | [SDK Core:生成与流式文本](./sdk-core/generating-text.md) | +| 使用结构化输出 | [SDK Core:生成结构化数据](./sdk-core/generating-structured-data.md) | +| 使用服务端工具、多步骤或审批 | [SDK Core:工具调用](./sdk-core/tools-and-tool-calling.md) | +| 定义可复用 Agent 与 UI Message 端点 | [SDK Core:Agent 运行时](./sdk-core/agents.md) | +| 生成向量、重排或组合 RAG | [SDK Core:Embedding、Rerank 与 RAG](./sdk-core/embeddings-reranking-and-rag.md) | +| 生成或编辑图片 | [SDK Core:图像生成](./sdk-core/image-generation.md) | +| 在 Vue 中构建聊天界面 | [SDK UI:Chatbot](./sdk-ui/chatbot.md) | +| 保存、恢复和校验聊天消息 | [SDK UI:消息持久化](./sdk-ui/chatbot-message-persistence.md) | +| 在前端执行工具或处理审批 | [SDK UI:工具交互](./sdk-ui/chatbot-tool-usage.md) | +| 在插件设置中选择模型 | [FormKit:模型选择器](./model-selector.md) | +| 自定义请求、Transport 或读取流 | [SDK UI:Transport 与读取消息流](./sdk-ui/transport-and-reading-streams.md) | +| 查询 SSE wire 格式 | [SDK UI:Stream Protocol](./sdk-ui/stream-protocol.md) | +| 按 Java 类型名查询完整 API | [SDK Core:公开 API 索引](./sdk-core/api-reference.md) | +| 按 npm 导出名查询完整 API | [SDK UI:公开导出索引](./sdk-ui/api-reference.md) | +| 在 Halo 插件中完成端到端集成 | [插件集成示例](./plugin-integration-examples.md) | ## SDK Core @@ -33,6 +34,7 @@ Java 后端的 SDK Core 与浏览器 / Vue 的 SDK UI,示例均以仓库公开 - 从 Halo 管理的模型资源解析语言、Embedding、Rerank 和图像生成模型。 - 非流式与流式文本生成、多轮消息、多模态输入和推理内容。 - JSON Schema 结构化输出、工具执行、工具审批、工具修复与多步骤控制。 +- 不可变 Agent 定义、类型化调用准备、默认有界步骤与 UI Message Agent 入口。 - Embedding、余弦相似度、Rerank、调用方自有检索和 RAG middleware。 - 请求级或模型级 middleware、取消、超时、生命周期事件、warning 与错误。 - 把模型 stream 转为供前端消费的 Halo UI Message stream。 diff --git a/dev/zh-CN/dev.md b/dev/zh-CN/dev.md index 50ca0b32..0fdf0e96 100644 --- a/dev/zh-CN/dev.md +++ b/dev/zh-CN/dev.md @@ -6,6 +6,9 @@ > 本文是便于全文搜索的 SDK Core 单页参考。按任务阅读时,建议从 > [开发者文档首页](./README.md) 或 [SDK Core 主题文档](./sdk-core/README.md) 开始。 +> +> 可复用定义、类型化调用准备、工具恢复和 UI Message Agent 端点请阅读 +> [Agent 运行时](./sdk-core/agents.md)。Agent 不包含持久化、调度、长期记忆或业务工具。 ## 1. 接入插件 diff --git a/dev/zh-CN/sdk-core/README.md b/dev/zh-CN/sdk-core/README.md index 24a87e95..bb3407b3 100644 --- a/dev/zh-CN/sdk-core/README.md +++ b/dev/zh-CN/sdk-core/README.md @@ -24,11 +24,12 @@ ExtensionGetter 2. [生成与流式文本](./generating-text.md) 3. [生成结构化数据](./generating-structured-data.md) 4. [工具调用与多步骤](./tools-and-tool-calling.md) -5. [Embedding、Rerank 与 RAG](./embeddings-reranking-and-rag.md) -6. [图像生成](./image-generation.md) -7. [Middleware、步骤控制与生命周期](./middleware-and-lifecycle.md) -8. [错误处理](./error-handling.md) -9. [完整公开 API 索引](./api-reference.md) +5. [Agent 运行时](./agents.md) +6. [Embedding、Rerank 与 RAG](./embeddings-reranking-and-rag.md) +7. [图像生成](./image-generation.md) +8. [Middleware、步骤控制与生命周期](./middleware-and-lifecycle.md) +9. [错误处理](./error-handling.md) +10. [完整公开 API 索引](./api-reference.md) ## API 入口速览 @@ -40,12 +41,13 @@ ExtensionGetter | 消息 | `ModelMessage`、`ModelMessagePart`、`DataContent` | | 结构化输出 | `OutputSpec`、`JsonSchema`、`StructuredSchema` | | 工具 | `ToolDefinition`、`ToolChoice`、`StopCondition`、`PreparedStep` | +| Agent | `Agent`、`AgentOptions`、`AgentCall`、`PreparedAgentCall` | | Embedding | `EmbeddingModel`、`EmbeddingRequest`、`EmbeddingUtils` | | Rerank | `RerankingModel`、`RerankRequest` | | RAG | `RagRetriever`、`RagMiddlewares`、`RagMiddlewareOptions` | | 图像 | `ImageGenerationModel`、`GenerateImageRequest`、`GeneratedFile` | | 控制 | `CancellationSource`、`GenerationTimeouts`、`GenerationLifecycle` | -| UI bridge | `UIMessageChatHandlers`、`UIMessageStreamResponse` | +| UI bridge | `UIMessageChatHandlers`、`UIMessageStreamResponse`、`streamAgent` | 需要按类型名查找完整公开面时,使用 [SDK Core:公开 API 索引](./api-reference.md)。 diff --git a/dev/zh-CN/sdk-core/agents.md b/dev/zh-CN/sdk-core/agents.md new file mode 100644 index 00000000..74fee363 --- /dev/null +++ b/dev/zh-CN/sdk-core/agents.md @@ -0,0 +1,196 @@ +# Agent 运行时 + +简体中文 | [English](../../en/sdk-core/agents.md) + +`run.halo.aifoundation.agent` 提供可复用、不可变且供应商中立的 Agent 定义。它在每次调用时 +组合一个新的 `GenerateTextRequest`,然后委托给现有 `LanguageModel`。工具循环、结构化输出、 +审批、外部工具、middleware、生命周期、取消和结果聚合仍由同一套模型运行时负责。 + +## 解析模型并创建定义 + +```java +record AnswerOptions(String style) { +} + +Mono> createAgent(AiModelService service, String modelName) { + return service.languageModel(modelName) + .map(model -> Agent.create(AgentOptions.forModel(model, AnswerOptions.class) + .id("site-assistant") + .instructions("Answer questions about this site.") + .maxOutputTokens(1024) + .tools(List.of(searchTool(), handoffTool())) + .callValidator(options -> { + if (options == null || options.style() == null) { + throw new IllegalArgumentException("style is required"); + } + }) + .prepareCall(context -> { + var suffix = switch (context.getOptions().style()) { + case "brief" -> " Keep the final answer brief."; + case "detailed" -> " Explain the answer in detail."; + default -> throw new IllegalArgumentException("unsupported style"); + }; + context.getRequestBuilder().system( + "Answer questions about this site." + suffix); + return Mono.just(context.prepared()); + }) + .build())); +} +``` + +定义会防御性复制集合与 map,适合作为 Bean 复用。不要在调用之间修改工具、metadata 或 +request builder;每次调用都会得到独立请求。没有显式 `stopWhen` 时,Agent 最多执行 20 个 +模型步骤,并在没有可执行的继续动作时提前结束。直接调用 `LanguageModel` 的默认步骤行为不变。 + +调用准备的顺序固定为:定义默认值、调用输入与运行控制、定义与调用级 middleware / lifecycle、 +一次异步 `prepareCall`、每个模型步骤的 `prepareStep`。`prepareCall` 可为当前调用替换模型或修改 +策略,并异步返回 `PreparedAgentCall`;它不会修改可复用定义。 + +## 调用与结果 + +```java +AgentCall call = AgentCall.builder() + .prompt("Summarize the latest article") + .options(new AnswerOptions("brief")) + .metadata(Map.of("operation", "summary")) + .context(Map.of("postName", "hello-halo")) + .cancellationToken(cancellation.token()) + .build(); + +Mono generated = agent.generate(call); +StreamTextResult streamed = agent.stream(call); + +Flux text = streamed.textStream(); +Mono finalResult = streamed.result(); +Mono> responseMessages = streamed.responseMessages(); +``` + +`generate` 与 `stream` 返回现有结果类型。流式结果的多个视图共享一次准备和一次 Provider 执行; +同时读取 `textStream()`、`fullStream()` 与 `result()` 不会重复工具或准备副作用。继续会话时保存 +`responseMessages`,不能只保存最终文本。 + +结构化输出放在定义的 `output` 中。最终值来自 `GenerateTextResult.getOutput()` 或 +`StreamTextResult.output()`;不要把助手文本再次解析成业务结果。 + +## 分步策略与生命周期 + +```java +Agent agent = Agent.create(AgentOptions + .forModel(model, AnswerOptions.class) + .instructions("Plan, then answer.") + .tools(List.of(planTool, answerTool)) + .prepareStep(context -> context.getStepIndex() == 0 + ? PreparedStep.builder().activeTools(List.of("plan")).build() + : PreparedStep.builder().activeTools(List.of("answer")).build()) + .stopWhen(StopCondition.stepCountIs(6)) + .lifecycle(List.of(lifecycle)) + .build()); +``` + +`prepareStep` 只影响当前模型步骤;调用准备只运行一次。用 `GenerationLifecycle` 观测请求、步骤、 +工具、审批和终止事件。生命周期用于诊断,不代替最终结果;回调失败会成为 warning。 + +定义级 `activeTools` 未设置时,所有已定义工具都可用;显式传入空列表会禁用全部工具。 +`prepareStep` 返回的非空 `activeTools` 优先于定义级设置,返回 `null` 表示该步骤不覆盖定义策略。 + +## 工具的完整生命周期 + +服务端工具包含 `executor`。没有 executor 的工具由调用方在外部完成。审批工具使用 +`needsApproval(true)` 或动态 `approvalPredicate`: + +```java +ToolDefinition publish = ToolDefinition.builder() + .name("publish") + .description("Publish a reviewed draft") + .inputSchema(Map.of( + "type", "object", + "properties", Map.of("postName", Map.of("type", "string")), + "required", List.of("postName") + )) + .needsApproval(true) + .executor(context -> publishingService.publish( + String.valueOf(context.getInput().get("postName")))) + .build(); + +ToolDefinition browserLookup = ToolDefinition.builder() + .name("browser_lookup") + .description("Look up data in the caller's browser") + .inputSchema(Map.of("type", "object")) + .build(); // 无 executor:外部工具 +``` + +审批请求、外部结果或错误必须作为 UI / Model Message 历史的一部分继续提交,并保留原始 +`toolCallId`。授权、幂等、审计和副作用确认属于消费插件的工具实现。 + +## 参数错误与工具更名恢复 + +`ToolCallRepairCallback` 处理两种明确失败: + +- `INVALID_INPUT`:工具存在,但输入不符合 schema;`context.getTool()` 非空。 +- `UNKNOWN_TOOL`:名称不在当前可用工具中;`context.getTool()` 为空, + `context.getAvailableTools()` 给出可选目标。 + +```java +.toolCallRepair(context -> { + var original = context.getToolCall(); + if (context.getFailureKind() == ToolCallFailureKind.UNKNOWN_TOOL + && original.getToolName().equals("legacy_search")) { + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(original.getToolCallId()) + .toolName("search") + .input(original.getInput()) + .build())); + } + if (context.getFailureKind() == ToolCallFailureKind.INVALID_INPUT + && original.getToolName().equals("search")) { + return Mono.just(ToolCallRepairResult.repaired(ToolCall.builder() + .toolCallId(original.getToolCallId()) + .toolName(original.getToolName()) + .input(Map.of("query", String.valueOf(original.getInput().get("q")))) + .build())); + } + return Mono.just(ToolCallRepairResult.unrepaired()); +}) +``` + +恢复必须保留调用 ID。已知工具输入修复不能换名;未知工具只能映射到当前可用工具。运行时会重新 +检查目标是否存在、输入 schema、审批与执行策略。回调缺失、抛错、改变 ID、指向不可用名称或仍然 +产生无效输入时,不会执行工具,而是保留安全错误并产生稳定 warning。执行器错误、审批拒绝、 +输出 schema 错误、取消和超时不进入恢复。 + +## Java UI Message endpoint 与浏览器续跑 + +```java +UIMessageChatResult chat = UIMessageChatHandlers.streamAgent( + agent, + chatRequest, + new AnswerOptions("brief"), + options -> options + .metadataSupplier(MyMetadata::empty) + .serializer(json::writeValueAsString) + .cancellationToken(cancellation.token()) + .onFinish(finish -> conversations.save(finish.messages())) +); + +UIMessageStreamResponse response = chat.response(); +``` + +端点负责从已认证请求派生类型化 call options;Transport 不能替换 Agent 的 instructions、tools、 +output 或 stop policy。`streamAgent` 与直接模型入口共用消息校验、转换、regenerate、推理历史策略、 +取消、聚合和 wire chunks。 + +浏览器继续使用现有 `Chat` / `useChat`、`DefaultChatTransport`、工具审批与 +`addToolOutput` / `addToolApprovalResponse`。没有 Agent 专用 Transport。持久化最终归并后的 +UI Message,恢复前用公开 schema 校验;自动续跑应使用有限步数与明确 predicate。 + +## 运行时边界 + +Agent 只负责一次调用的策略组合与模型执行。消费插件仍然拥有: + +- 会话、UI Message、run、checkpoint 与业务状态的持久化; +- 中断后恢复、后台调度、重试队列、分布式协调和定时任务; +- 长期记忆、检索索引、租户与用户授权、配额、速率限制和审计; +- 浏览器、编辑器、内容发布、网络访问等业务工具及其副作用安全。 + +因此,进程重启后不能仅凭 `Agent` 对象恢复一次未完成运行。需要持久运行时的消费插件应保存消息、 +工具状态和业务 checkpoint,再创建新的 Agent 调用继续。 diff --git a/dev/zh-CN/sdk-core/api-reference.md b/dev/zh-CN/sdk-core/api-reference.md index cf9f52a9..9fc63c31 100644 --- a/dev/zh-CN/sdk-core/api-reference.md +++ b/dev/zh-CN/sdk-core/api-reference.md @@ -37,6 +37,22 @@ 模型选择器使用同一套 capability 语义,详见 [FormKit:AI 模型选择器](../model-selector.md)。 +## Agent 运行时 + +| 类型 | 用途 | +| -------------------------------------- | ------------------------------------------------------------------------------- | +| `Agent` | 不可变、可复用的类型化 Agent 定义及 `generate` / `stream` 入口。 | +| `AgentOptions` | 模型、instructions、工具、输出、步骤、恢复、采样和默认运行控制。 | +| `AgentCall` | 单次 prompt 或 messages、类型化 options、metadata、context、取消和 middleware。 | +| `AgentCallValidator` | 在 Provider 调用前校验端点拥有的类型化 options。 | +| `AgentCallPrepare` | 一次性异步修改当前调用请求或替换当前调用模型。 | +| `AgentCallPrepareContext` | 当前调用、options、基础模型和新请求 builder。 | +| `PreparedAgentCall` | 调用准备后的有效模型与请求。 | +| `AgentCallPhase`、`AgentCallException` | 区分 validation 与 preparation 阶段的失败。 | + +Agent 在没有显式 stop condition 时最多执行 20 步;直接 `LanguageModel` 默认行为不变。完整构造、 +恢复、UI Message 端点和持久化边界见 [Agent 运行时](./agents.md)。 + ## 文本生成 ### 模型、请求与结果 @@ -154,20 +170,20 @@ ## 工具与多步骤 -| 类型 | 用途 | -| ------------------------------------------------------------------------- | ------------------------------------------------------------ | -| `ToolDefinition` | 工具名、描述、输入 schema、执行器、审批、strict 和输入示例。 | -| `ToolChoice` | auto、none、required 或指定工具。 | -| `ToolExecutor` | 在 AI Foundation 后端执行工具。 | -| `ToolExecutionContext` | 工具执行时可读取的调用 ID、消息、metadata 和 context。 | -| `ToolCall`、`ToolResult`、`ToolError` | 模型请求、成功输出和失败输出的持久结果。 | -| `ToolInputParseError` | 工具输入无法按 schema 解析。 | -| `ToolApprovalPolicy`、`ToolApprovalPredicate` | 声明工具是否总是、从不或按输入等待审批。 | -| `ToolApprovalRequest`、`ToolApprovalResponse` | 跨请求保存审批请求和调用方决定。 | -| `ToolCallRepairCallback`、`ToolCallRepairContext`、`ToolCallRepairResult` | 已知服务端工具的输入修复钩子及结果。 | -| `ToolInputStartCallback`、`ToolInputStartContext` | 观察工具输入开始。 | -| `ToolInputDeltaCallback`、`ToolInputDeltaContext` | 观察工具输入文本增量。 | -| `ToolInputAvailableCallback`、`ToolInputAvailableContext` | 观察完整且已解析的工具输入。 | +| 类型 | 用途 | +| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | +| `ToolDefinition` | 工具名、描述、输入 schema、执行器、审批、strict 和输入示例。 | +| `ToolChoice` | auto、none、required 或指定工具。 | +| `ToolExecutor` | 在 AI Foundation 后端执行工具。 | +| `ToolExecutionContext` | 工具执行时可读取的调用 ID、消息、metadata 和 context。 | +| `ToolCall`、`ToolResult`、`ToolError` | 模型请求、成功输出和失败输出的持久结果。 | +| `ToolInputParseError` | 工具输入无法按 schema 解析。 | +| `ToolApprovalPolicy`、`ToolApprovalPredicate` | 声明工具是否总是、从不或按输入等待审批。 | +| `ToolApprovalRequest`、`ToolApprovalResponse` | 跨请求保存审批请求和调用方决定。 | +| `ToolCallFailureKind`、`ToolCallRepairCallback`、`ToolCallRepairContext`、`ToolCallRepairResult` | 已知工具无效输入与未知/更名工具的统一恢复契约。 | +| `ToolInputStartCallback`、`ToolInputStartContext` | 观察工具输入开始。 | +| `ToolInputDeltaCallback`、`ToolInputDeltaContext` | 观察工具输入文本增量。 | +| `ToolInputAvailableCallback`、`ToolInputAvailableContext` | 观察完整且已解析的工具输入。 | `ToolDefinition` 的主要 builder 字段为 `name`、`description`、`inputSchema`、`executor`、 `strict`、`inputExamples`、`approvalPolicy` 和 `approvalPredicate`。服务端执行、外部工具、 @@ -276,18 +292,21 @@ | 类型 | 用途 | | ----------------------------------------------------- | ----------------------------------------------------------------------- | -| `UIMessageChatHandlers` | 校验、转换、准备请求、执行模型并创建 UI Message stream。 | -| `UIMessageChatOptions` | 配置模型、消息、请求、middleware、校验、转换、取消和回调。 | +| `UIMessageChatHandlers` | 校验、转换、准备请求,执行模型或类型化 Agent 并创建 UI Message stream。 | +| `UIMessageChatOptions` | 互斥配置模型或 Agent,以及消息、middleware、校验、转换、取消和回调。 | | `UIMessageChatRequest`、`UIMessageChatTrigger` | 前端提交的会话、触发方式和 regenerate 目标。 | | `UIMessageChatPrepare`、`UIMessageChatPrepareContext` | 执行前异步补充业务请求配置。 | | `UIMessageChatResult` | stream、HTTP response、校验、转换和最终消息的组合结果。 | | `UIMessageCancellation`、`UIMessageCancellations` | 创建调用方拥有的 token,并在订阅者取消 Flux / Mono 时联动取消模型调用。 | -`UIMessageChatOptions` 的公开链式配置为 `model`、`messages`、`chatRequest`、`message`、 +`UIMessageChatOptions` 的公开链式配置为 `model` / `agent`(二选一)、`messages`、`chatRequest`、`message`、 `metadataSupplier`、`generateMessageId`、`serializer`、`request`、`prepare`、 `middleware`、`validation`、`conversion`、`onFinish`、`onError`、`onReadError`、 `cancellationToken` 和 `terminateOnError`。 +Agent 端点优先使用类型安全的 `UIMessageChatHandlers.streamAgent(...)`,Transport 请求不能覆盖 +Agent 的 semantic policy。详见 [Agent 运行时](./agents.md)。 + ### 校验与转换 | 类型 | 用途 | diff --git a/openspec/changes/add-agent-runtime/.openspec.yaml b/openspec/changes/add-agent-runtime/.openspec.yaml new file mode 100644 index 00000000..a8821c74 --- /dev/null +++ b/openspec/changes/add-agent-runtime/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-11 diff --git a/openspec/changes/add-agent-runtime/design.md b/openspec/changes/add-agent-runtime/design.md new file mode 100644 index 00000000..d2de4a05 --- /dev/null +++ b/openspec/changes/add-agent-runtime/design.md @@ -0,0 +1,133 @@ +## Context + +`LanguageModel` already owns provider-neutral text generation, structured output, multi-step tool execution, `stopWhen`, `prepareStep`, tool approval, external tool continuation, repair of known-tool input, lifecycle events, cancellation, timeout, middleware, and `StreamTextResult`. `UIMessageChatHandlers` already validates persisted UI messages, converts them to model messages, executes a model, and publishes the canonical browser stream. + +Consumer plugins can assemble those primitives into an agent today, but they must repeat configuration merging, multi-step defaults, call preparation, cancellation wiring, and UI Message endpoint code. Repeating that work makes request isolation, recovery, and lifecycle behavior depend on each consumer. The new runtime must therefore be an orchestration layer over the existing model runtime, not a parallel execution engine. + +The public API is consumed across isolated Halo plugin classloaders. It must remain in the `api` module, depend only on provider-neutral AI Foundation DTOs and Reactor, and avoid Spring AI implementation types. + +## Goals / Non-Goals + +**Goals:** + +- Provide one immutable, reusable agent definition with complete semantic generation defaults. +- Provide typed call options, runtime validation, and asynchronous per-call preparation. +- Produce the existing normalized non-streaming and streaming results without semantic drift. +- Make multi-step execution bounded by default and allow explicit caller-owned stop policies. +- Recover safely from invalid input and unknown or renamed tool calls. +- Preserve tool approval, external tools, structured output, middleware, lifecycle, cancellation, timeout, warnings, and response messages. +- Reuse the existing Java UI Message validation, conversion, aggregation, and wire protocol. +- Supply a complete workbench, automated test suite, and caller documentation in the same change. + +**Non-Goals:** + +- Durable conversation, run, checkpoint, or memory storage. +- Background scheduling, restart recovery, distributed coordination, or autonomous job ownership. +- Business-specific planning algorithms or built-in browser, editor, content, or network tools. +- A new provider adapter contract, a second model loop, or a second frontend protocol. +- Caller-writable provider-native option maps. + +## Decisions + +### 1. Add a native agent package over `LanguageModel` + +The published API will add a cohesive `run.halo.aifoundation.agent` package. Its public surface will include an immutable `Agent`, an `AgentOptions` builder, an immutable `AgentCall`, call validation and preparation callbacks, a request-scoped preparation context, and an effective prepared-call value. + +`Agent` will expose `generate(AgentCall)` and `stream(AgentCall)`. These methods will return `Mono` and `StreamTextResult`; no agent-specific duplicate result hierarchy will be introduced. + +The agent builder will expose all stable semantic settings required for a complete definition: id, model, instructions, tools, active tools, tool choice, output, stop condition, step preparation, tool recovery, reasoning and sampling settings, retries, headers, middleware, lifecycle defaults, and timeouts. Mutable inputs and collections will be defensively copied when the agent is built. + +Alternative considered: add helper methods directly to `LanguageModel`. Rejected because reusable identity, call-option validation, preparation, and policy composition are a distinct concern and would make the model interface stateful. + +### 2. Keep call inputs narrow and policy-owned + +`AgentCall` will carry either a prompt or model messages, typed call options, metadata, request context, request headers, cancellation, timeouts, lifecycle observers, and request middleware. It will not accept raw tools, instructions, stop conditions, output schemas, or provider-native settings directly. + +This split lets an endpoint pass user input and operational controls without allowing an untrusted caller to replace the agent's policy. A consumer that owns the agent can intentionally change policy through the typed call preparation hook. + +Alternative considered: let every call provide an arbitrary `GenerateTextRequest`. Rejected because it makes the agent definition advisory and creates ambiguous precedence for tools, instructions, output, and stop policy. + +### 3. Validate and prepare every call asynchronously + +An optional `AgentCallValidator` will validate typed options before any model call. An optional `AgentCallPrepare` will receive an `AgentCallPrepareContext` containing the immutable call, the agent's base model, and a fresh effective request builder. It may asynchronously update that request and replace the model for the current call only. + +Preparation runs exactly once per agent call, before generation lifecycle start and before `prepareStep`. The effective request is validated again through the existing language-model validator. Preparation failure terminates the call without invoking the provider. + +Precedence is deterministic: + +1. Agent definition defaults initialize a fresh request. +2. Prompt/messages and operational fields from `AgentCall` are applied. +3. Call-scoped lifecycle and middleware are composed after definition-level entries while preserving list order. +4. The asynchronous call preparer makes final policy-owned changes. +5. Existing `prepareStep` may change only step-scoped settings during execution. + +Alternative considered: reuse `prepareStep` for call preparation. Rejected because model selection, option validation, and one-time retrieval or tenant policy must happen once before lifecycle and step execution. + +### 4. Bound agent loops by default + +An agent that does not declare a stop condition will use a built-in maximum of 20 model steps. Execution still finishes earlier when the existing runtime has no executable continuation. Consumers may provide another `StopCondition`, including a smaller limit or a compound business rule. + +Direct `LanguageModel` calls retain their current single-step default. Only the agent abstraction receives the bounded multi-step default. + +Alternative considered: inherit the model request's single-step default. Rejected because it would make a default agent unable to complete ordinary tool-result round trips. + +### 5. Reuse the existing execution engine + +Agent execution will build one validated `GenerateTextRequest` and delegate to the selected `LanguageModel`. The existing runtime remains authoritative for steps, tools, structured output, stream replay, response messages, warnings, usage, lifecycle, cancellation, timeout, and middleware. + +The agent layer must not inspect Spring AI responses or reproduce the tool loop. Non-streaming and streaming calls must therefore differ only at the final `generateText` versus `streamText` delegation point. + +Alternative considered: implement a dedicated agent loop. Rejected because it would create two sources of truth for tool approvals, repair, continuation, and stream ordering. + +### 6. Expand tool recovery with explicit failure kinds + +The tool recovery contract will introduce a provider-neutral failure kind with at least `INVALID_INPUT` and `UNKNOWN_TOOL`. The recovery context will always contain the original call, available request tools, step messages, step index, request context, and provider metadata. The matching tool is present for invalid input and absent for an unknown tool. + +For invalid input, a repaired call must retain the original tool name and call id. For an unknown tool, recovery may map the call to one currently available tool, but it must retain the call id. Every repaired call is revalidated for tool existence, input schema, approval, and execution policy before it becomes available or executes. + +Failed, absent, invalid, or callback-throwing recovery produces the existing safe tool error and a stable warning. It never executes an unvalidated tool. Executor failures, denied approval, output-schema failures, cancellation, and timeout are not recovery inputs. + +For streamed unknown tools, the runtime accumulates the provider input under the stable call id. Definition-specific input callbacks run only after a tool has been resolved; the runtime then replays one start, the accumulated input, and one available callback in canonical order. UI Message reduction finalizes the repaired name for the same call id instead of creating a second tool part. + +Alternative considered: add a separate alias map. Rejected because a callback can use request context, tool metadata, version information, and policy, while the same validation path handles both static aliases and model mistakes. + +### 7. Reuse the UI Message handler pipeline + +`UIMessageChatHandlers` will gain an agent execution entry point. It will reuse the existing trigger handling, message validation, conversion, reasoning policy, cancellation, stream aggregation, callbacks, serializer, and response construction. Converted model messages become the agent call messages, and endpoint-owned typed call options are supplied separately. + +The internal handler pipeline will accept one execution function so model and agent entry points share all transport behavior. Exactly one of a model execution or agent execution may be selected. The browser SDK and stream chunk schema do not gain an agent-specific protocol. + +Alternative considered: create a separate agent transport and frontend hook. Rejected because an agent produces the same UI Message parts and a second protocol would fragment persistence and tooling. + +### 8. Deliver diagnostics, tests, and documentation together + +The console workbench will add an agent mode that calls the public agent API through the backend. It will expose the effective bounded step policy, a typed call option that changes request preparation, server and external tools, approval, invalid-input repair, unknown-tool recovery, structured output, cancellation, and step/warning diagnostics. Existing generated clients and browser chat primitives remain authoritative. + +Automated verification will cover public API construction, defensive copies, concurrent calls, preparation ordering and failure, default/custom stop policies, generate/stream parity, all recovery outcomes, tool callback ordering, UI Message behavior, and workbench request assembly. Documentation and examples are part of the same completion gate. + +Alternative considered: ship the Java API first and add UI, tests, and docs later. Rejected because that would expose an unproven partial runtime to consumer plugins. + +## Risks / Trade-offs + +- **[Large public API surface]** → Keep all agent types in one package, reuse existing request/result types, and add compile-time API shape tests. +- **[Mutable request objects leak between concurrent calls]** → Store normalized immutable definition values and build a fresh request and preparation context per invocation. +- **[Agent and model settings have ambiguous precedence]** → Enforce the five-level precedence order above and test each conflicting field. +- **[Unknown-tool recovery executes an unintended tool]** → Require an explicit callback, restrict the repaired name to the current available tool set, preserve call identity, and run full schema and approval validation. +- **[Streaming repair changes a provisional tool name]** → Key lifecycle and UI reduction by call id, delay definition callbacks until resolution, and test interleaved calls. +- **[Default multi-step execution consumes unexpected tokens]** → Use a fixed upper bound, finish early when continuation is impossible, expose step usage, and document cost implications. +- **[Workbench-only behavior diverges from public runtime]** → The endpoint must construct and execute the published agent types; no console-only agent engine is allowed. +- **[Agent scope overlaps durable business runtimes]** → Keep persistence, scheduling, restart recovery, and business tools explicitly out of the API and documentation. + +## Migration Plan + +1. Add the public agent and expanded recovery types without changing existing direct model call defaults. +2. Implement request composition over `LanguageModel` and complete automated runtime tests. +3. Refactor the UI Message handler around the shared execution function and add agent entry-point tests while preserving existing model tests. +4. Add the workbench agent mode through generated endpoint clients. +5. Publish Java and UI documentation and run the full API, backend, frontend, and OpenSpec quality gates. + +The plugin is unreleased, so no legacy shim or data migration is required. Rollback consists of reverting the change; no persisted resource schema or conversation data is introduced. + +## Open Questions + +None are blocking. Public type names may be adjusted during implementation only to avoid Java erasure or package-name collisions; the behavioral boundaries and complete delivery scope defined here remain fixed. diff --git a/openspec/changes/add-agent-runtime/proposal.md b/openspec/changes/add-agent-runtime/proposal.md new file mode 100644 index 00000000..8a6a57e0 --- /dev/null +++ b/openspec/changes/add-agent-runtime/proposal.md @@ -0,0 +1,47 @@ +## Why + +AI Foundation already provides multi-step model execution, tools, structured output, lifecycle controls, and UI Message streaming, but consumer plugins must assemble these primitives for every reusable agent. A native agent runtime will provide one complete, bounded, and observable orchestration contract while keeping durable conversations and business workflows outside the foundation layer. + +## What Changes + +- Add an immutable, reusable agent definition that owns its model, instructions, tools, output contract, step policy, middleware, lifecycle defaults, and tool recovery policy. +- Add typed per-call input and asynchronous call preparation so consumers can validate business options, select or replace model settings, and inject request-scoped context without mutating shared agent state. +- Add matching non-streaming and streaming execution APIs that reuse `GenerateTextResult`, `StreamTextResult`, cancellation, timeout, warning, response-message, and provider-neutral metadata contracts. +- Extend tool-call recovery to cover unknown or renamed tools as well as invalid input, with explicit error kinds, available-tool context, stable call identity, full revalidation, and safe fallback when recovery fails. +- Integrate agents with the existing Java UI Message chat handler and browser stream protocol so the current Vue chat runtime can consume agent output without a second transport. +- Add an administrator workbench flow that exercises agent instructions, bounded multi-step execution, dynamic call preparation, tool approval, external tools, unknown-tool recovery, structured output, cancellation, and streaming. +- Publish caller-oriented Java and UI documentation, complete examples, and deterministic automated tests for the full agent lifecycle. +- Keep the implementation provider-neutral and independent from Spring AI types in the public API. + +### Non-goals + +- Persisting conversations, runs, checkpoints, or business state. +- Scheduling background work, resuming a process after application restart, or coordinating distributed workers. +- Defining business-specific planning, memory, browser, editor, or content tools. +- Adding a second model execution engine or a second frontend stream protocol. +- Exposing raw provider option maps to consumer plugins. + +This is an end-to-end backend and frontend workbench change. The reusable browser SDK continues using its existing wire contract; the workbench gains agent-specific controls and diagnostics. + +## Capabilities + +### New Capabilities + +- `agent-runtime`: Immutable agent definition, typed call preparation, bounded generate/stream execution, lifecycle composition, request isolation, and UI Message integration. + +### Modified Capabilities + +- `ai-model-service`: Expand tool-call recovery from known-tool input errors to a typed recovery contract that can safely repair unknown or renamed tools. +- `ui-message-stream`: Allow the Java chat handler to execute a configured agent while preserving validation, conversion, cancellation, callbacks, and the existing stream protocol. +- `model-test-workbench`: Add one complete agent workbench flow covering the public runtime and its tool, output, cancellation, and streaming behaviors. +- `consumer-sdk-documentation`: Document agent construction, call preparation, execution, UI integration, tool recovery, lifecycle boundaries, and non-goals. +- `sdk-ergonomics`: Make the agent API discoverable, typed, immutable, provider-neutral, and covered by public API quality gates. + +## Impact + +- **Published Java API:** new `run.halo.aifoundation.agent` package and additions to the provider-neutral tool recovery context. +- **Backend runtime:** agent request composition over the existing `LanguageModel`, tool validation and recovery orchestration, and UI Message handler integration. +- **Console UI:** agent mode and diagnostics in the model test workbench, using generated API clients and the existing browser SDK. +- **Tests:** public API construction tests, request-isolation and concurrency tests, non-streaming/streaming parity tests, recovery tests, UI Message integration tests, and workbench tests. +- **Documentation:** Java SDK Core and SDK UI guides plus API reference updates. +- **Dependencies:** no new provider or persistence dependency is required; implementation continues to use Reactor and existing Halo extension boundaries. diff --git a/openspec/changes/add-agent-runtime/specs/agent-runtime/spec.md b/openspec/changes/add-agent-runtime/specs/agent-runtime/spec.md new file mode 100644 index 00000000..49f42c6d --- /dev/null +++ b/openspec/changes/add-agent-runtime/specs/agent-runtime/spec.md @@ -0,0 +1,196 @@ +## ADDED Requirements + +### Requirement: Consumers can define immutable reusable agents +The system SHALL provide a provider-neutral public agent definition that captures stable model orchestration policy and can be reused safely across requests. + +#### Scenario: Agent is built with complete defaults +- **WHEN** a consumer builds an agent with an id, model, instructions, tools, output, stop policy, generation settings, middleware, lifecycle, and recovery callback +- **THEN** the agent SHALL retain a defensively copied immutable definition +- **AND** later changes to caller-owned collections or builders MUST NOT alter the agent + +#### Scenario: Agent omits optional policy +- **WHEN** a consumer builds an agent with only a model and instructions +- **THEN** the system SHALL supply documented defaults for tools, output, middleware, lifecycle, recovery, and step control + +### Requirement: Agent calls use typed isolated input +The system SHALL provide an immutable typed agent call that separates user input and operational controls from agent-owned policy. + +#### Scenario: Prompt call is accepted +- **WHEN** a call contains a prompt and typed call options +- **THEN** the runtime SHALL create a fresh effective model request for that invocation + +#### Scenario: Message call is accepted +- **WHEN** a call contains provider-neutral model messages and typed call options +- **THEN** the runtime SHALL preserve the messages without mutating the caller list + +#### Scenario: Conflicting call input is rejected +- **WHEN** a call contains both a prompt and messages or contains neither +- **THEN** the runtime MUST reject the call before preparation or provider execution + +#### Scenario: Call cannot replace agent policy directly +- **WHEN** a caller constructs an agent call +- **THEN** the call API SHALL NOT expose direct replacement fields for instructions, tools, output, stop conditions, or provider-native options + +### Requirement: Typed call options are validated before execution +The system SHALL allow an agent definition to declare runtime validation for its typed call options. + +#### Scenario: Valid options continue to preparation +- **WHEN** the call-option validator accepts the supplied options +- **THEN** asynchronous call preparation SHALL receive the validated value + +#### Scenario: Invalid options stop the call +- **WHEN** the call-option validator rejects the supplied options +- **THEN** the call SHALL fail with a stable validation error +- **AND** preparation, lifecycle start, and provider execution MUST NOT run + +#### Scenario: Agent does not require custom options +- **WHEN** an agent uses the no-options form and the caller supplies ordinary prompt or messages +- **THEN** the runtime SHALL execute without requiring a placeholder map or provider-specific DTO + +### Requirement: Agents support asynchronous call preparation +The system SHALL allow one asynchronous preparation callback to derive the effective model and model request for each call. + +#### Scenario: Preparation changes the current call +- **WHEN** preparation replaces the model or changes instructions, tools, output, semantic generation settings, middleware, or step policy +- **THEN** those changes SHALL apply only to the current call + +#### Scenario: Preparation receives complete context +- **WHEN** preparation begins +- **THEN** it SHALL receive the immutable call, validated typed options, base model, and a fresh effective request builder +- **AND** it SHALL be able to read metadata and request context without adding them to the prompt automatically + +#### Scenario: Preparation runs once +- **WHEN** an agent executes multiple model steps +- **THEN** call preparation MUST run exactly once before the first generation lifecycle event +- **AND** step preparation SHALL remain responsible for per-step changes + +#### Scenario: Preparation fails +- **WHEN** call preparation throws, returns an error, or produces an invalid effective request +- **THEN** the agent call SHALL fail before provider execution +- **AND** no partially prepared state SHALL be retained for another call + +### Requirement: Agent request composition has deterministic precedence +The system SHALL compose definition defaults, call input, operational controls, call preparation, and step preparation in a documented deterministic order. + +#### Scenario: Definition initializes the request +- **WHEN** a call starts +- **THEN** the runtime SHALL copy agent instructions, tools, output, stop policy, semantic generation defaults, middleware, lifecycle, and recovery into a fresh request + +#### Scenario: Operational controls compose safely +- **WHEN** the call supplies metadata, context, headers, cancellation, timeouts, lifecycle observers, or middleware +- **THEN** the runtime SHALL apply them without mutating definition-owned values +- **AND** definition-level middleware and lifecycle behavior SHALL run before call-scoped entries unless the public contract explicitly states otherwise + +#### Scenario: Call preparation is final before execution +- **WHEN** call preparation changes a definition-derived setting +- **THEN** the prepared value SHALL be the input to normal language-model request validation + +#### Scenario: Step preparation remains step-scoped +- **WHEN** the effective request declares `prepareStep` +- **THEN** its overrides SHALL apply only according to the existing step-control contract + +### Requirement: Agents execute bounded multi-step runs by default +The system SHALL provide useful tool-loop behavior while preventing unbounded execution. + +#### Scenario: Default stop policy is used +- **WHEN** an agent definition omits a stop condition +- **THEN** the runtime SHALL allow at most 20 model steps +- **AND** it SHALL finish earlier when the underlying execution has no executable continuation + +#### Scenario: Custom stop policy is used +- **WHEN** an agent definition or call preparation supplies a stop condition +- **THEN** the runtime SHALL use that condition instead of the default agent step limit + +#### Scenario: Direct model defaults remain unchanged +- **WHEN** a consumer invokes `LanguageModel` directly without `stopWhen` +- **THEN** the existing direct-call single-step behavior SHALL remain unchanged + +### Requirement: Agent generate and stream share one execution semantics +The system SHALL delegate agent execution to the selected `LanguageModel` and SHALL return the existing normalized result types. + +#### Scenario: Non-streaming generation completes +- **WHEN** a consumer invokes agent generation +- **THEN** the runtime SHALL return `GenerateTextResult` with text, structured output, reasoning, sources, files, steps, tools, warnings, usage, response messages, and metadata produced by the existing model runtime + +#### Scenario: Streaming generation completes +- **WHEN** a consumer invokes agent streaming +- **THEN** the runtime SHALL return `StreamTextResult` with the existing full stream, text stream, structured output projections, final result, and UI Message conversion helpers + +#### Scenario: Equivalent calls have equivalent terminal state +- **WHEN** deterministic provider fixtures execute the same prepared call through generate and stream +- **THEN** their final steps, tool outcomes, finish reason, usage, warnings, response messages, and structured output SHALL be semantically equivalent + +#### Scenario: Agent does not implement a second tool loop +- **WHEN** an agent executes tools or multiple model steps +- **THEN** the existing language-model runtime SHALL remain the authoritative execution engine + +### Requirement: Agent calls preserve lifecycle and operational controls +The system SHALL preserve existing lifecycle, cancellation, timeout, retry, middleware, and warning behavior across agent execution. + +#### Scenario: Lifecycle observes the full agent call +- **WHEN** an agent executes multiple steps and tools +- **THEN** definition-level and call-scoped lifecycle observers SHALL receive the existing start, step, tool, approval, finish, and error events exactly once according to their contract + +#### Scenario: Call is cancelled +- **WHEN** the call cancellation token is cancelled during preparation, provider execution, tool execution, or streaming +- **THEN** the runtime SHALL stop safely using the existing typed cancellation behavior + +#### Scenario: Timeout expires +- **WHEN** a configured total, step, or tool timeout expires +- **THEN** the runtime SHALL apply the existing timeout scope and typed exception behavior + +#### Scenario: Middleware is composed +- **WHEN** definition and call middleware are present +- **THEN** they SHALL wrap the prepared request in deterministic list order without being applied twice + +### Requirement: Agent definitions are safe for concurrent reuse +The system SHALL isolate all mutable call, preparation, stream, and result state per invocation. + +#### Scenario: Concurrent calls use different options +- **WHEN** two calls execute concurrently on the same agent with different options, context, models selected by preparation, or cancellation tokens +- **THEN** each call SHALL observe only its own effective request and terminal result + +#### Scenario: One call fails +- **WHEN** preparation, validation, provider execution, or a tool fails for one concurrent call +- **THEN** the other call SHALL continue without shared error or cancellation state + +#### Scenario: Multiple stream views are consumed +- **WHEN** a caller consumes more than one projection from an agent `StreamTextResult` +- **THEN** the provider, preparation callback, tool callbacks, and lifecycle callbacks SHALL each run at most once for the corresponding call + +### Requirement: Agent public contracts remain provider-neutral +The public agent API SHALL use AI Foundation messages, tools, schemas, lifecycle types, model interfaces, and provider-neutral metadata only. + +#### Scenario: Consumer compiles against the API module +- **WHEN** another Halo plugin compiles an agent without the implementation module +- **THEN** no Spring AI class SHALL appear in the public signature or required construction path + +#### Scenario: Provider-native option maps remain unavailable +- **WHEN** a consumer inspects agent definition, call, preparation, and prepared-call types +- **THEN** no caller-writable provider-native option map SHALL be present + +### Requirement: Agent UI Message execution reuses the canonical stream +The system SHALL allow agent streams to serve browser chat clients through the existing UI Message contract. + +#### Scenario: Agent stream is converted directly +- **WHEN** a consumer calls the existing UI Message conversion on an agent `StreamTextResult` +- **THEN** text, reasoning, source, file, step, tool, approval, finish, abort, and error parts SHALL use the current chunk schema + +#### Scenario: No agent-specific wire type is introduced +- **WHEN** a browser client consumes an agent response +- **THEN** it SHALL use the existing stream version, reducer, persistence shape, and Vue chat actions without an agent-specific transport + +### Requirement: Agent failures remain explicit and inspectable +The system SHALL distinguish call validation, call preparation, model resolution, generation, tool recovery, cancellation, and timeout failures without hiding them behind a generic agent error. + +#### Scenario: Pre-provider failure occurs +- **WHEN** validation or call preparation fails before provider execution +- **THEN** the returned error SHALL identify that phase and SHALL NOT report a generation step that did not occur + +#### Scenario: Generation emits warnings +- **WHEN** preparation, recovery, provider adaptation, or structured output produces a non-fatal warning +- **THEN** the warning SHALL be retained in the existing result and stream warning surfaces + +#### Scenario: Stream fails after starting +- **WHEN** an agent stream fails after emitting chunks +- **THEN** it SHALL use the existing terminal error and aggregation behavior diff --git a/openspec/changes/add-agent-runtime/specs/ai-model-service/spec.md b/openspec/changes/add-agent-runtime/specs/ai-model-service/spec.md new file mode 100644 index 00000000..9093114e --- /dev/null +++ b/openspec/changes/add-agent-runtime/specs/ai-model-service/spec.md @@ -0,0 +1,79 @@ +## MODIFIED Requirements + +### Requirement: Tool Call Repair +The language model service SHALL support caller-provided recovery of invalid or unknown model-produced tool calls before approval, external handoff, or server-side execution. + +#### Scenario: Recovery context identifies invalid known-tool input +- **WHEN** a provider returns a tool call whose name matches a request tool and whose input fails that tool's input schema +- **AND** the request includes a tool-call recovery callback +- **THEN** the system SHALL invoke the callback with failure kind `INVALID_INPUT` +- **AND** the context SHALL include the original call, matching tool, complete available tool list, validation details, step index, provider messages, request context, and provider metadata + +#### Scenario: Repaired known-tool input executes +- **WHEN** recovery returns a call with the original tool name and call id +- **AND** the repaired input satisfies the original tool input schema +- **THEN** the system SHALL continue normal approval, external handoff, or server-side execution using the repaired input +- **AND** the step SHALL record a stable warning that the tool call was repaired +- **AND** `GenerateTextResult.responseMessages` SHALL contain the repaired assistant tool-call message before any matching tool result or error message + +#### Scenario: Recovery context identifies an unknown tool +- **WHEN** a provider returns a named tool call that is absent from the current available tools +- **AND** the request includes a tool-call recovery callback +- **AND** at least one request tool is currently available +- **THEN** the system SHALL invoke the callback with failure kind `UNKNOWN_TOOL` +- **AND** the context SHALL include the original call, no matching tool, the complete available tool list, step state, messages, request context, and provider metadata + +#### Scenario: Unknown tool is mapped to an available tool +- **WHEN** unknown-tool recovery returns the name of a currently available tool with the original call id +- **AND** the returned input satisfies that tool's input schema +- **THEN** the system SHALL continue normal approval, external handoff, or server-side execution using the resolved tool +- **AND** the step SHALL record a stable warning containing safe original-name and resolved-name diagnostics +- **AND** response messages SHALL contain only the resolved tool call for continuation + +#### Scenario: Recovered tool preserves call identity +- **WHEN** recovery succeeds for invalid input or an unknown tool +- **THEN** the repaired call MUST retain the provider's original non-blank tool call id +- **AND** downstream approval, UI Message parts, results, errors, and continuation SHALL use that same id + +#### Scenario: Recovered call is fully revalidated +- **WHEN** a recovery callback returns a tool call +- **THEN** the system SHALL validate name availability, input schema, approval policy, external-tool state, and executor eligibility before making the input available or executing it +- **AND** it MUST NOT trust callback output as already valid + +#### Scenario: Recovery is not configured +- **WHEN** a provider returns invalid known-tool input or an unknown tool +- **AND** the request does not include a recovery callback +- **THEN** the system SHALL retain the existing safe validation or unknown-tool error +- **AND** it SHALL NOT execute the tool + +#### Scenario: Unknown tool has no recovery target +- **WHEN** a provider returns an unknown tool and the current available tool set is empty +- **THEN** the system SHALL record an unknown-tool error without invoking recovery +- **AND** it SHALL NOT execute the tool + +#### Scenario: Recovery fails safely +- **WHEN** the callback fails, returns no repaired call, changes the call id, returns a disallowed name, returns a missing tool, or returns input that still fails validation +- **THEN** the system SHALL record the original safe validation or unknown-tool error +- **AND** it SHALL NOT execute the tool +- **AND** it SHALL report a stable warning that recovery was attempted and failed + +#### Scenario: Non-tool-input failures are not recovered +- **WHEN** a server-side executor fails, output schema validation fails, approval is denied, a tool times out, or generation is cancelled +- **THEN** the system SHALL use the existing error, denial, timeout, or cancellation behavior +- **AND** it SHALL NOT invoke tool-call recovery + +#### Scenario: Streamed unknown tool resolves before availability +- **WHEN** a streamed tool call finishes with an unknown name and recovery succeeds +- **THEN** the runtime SHALL retain the accumulated input under the original call id +- **AND** it SHALL publish input availability only after the resolved tool and input pass validation +- **AND** the UI Message reducer SHALL finalize the resolved name on the existing call part instead of creating a second part + +#### Scenario: Resolved tool callbacks run in canonical order +- **WHEN** streamed unknown-tool recovery resolves to a tool with input lifecycle callbacks +- **THEN** the runtime SHALL invoke one input-start callback, replay the accumulated input through the input-delta callback, and invoke one input-available callback in that order +- **AND** approval, external handoff, or execution MUST wait for those callbacks + +#### Scenario: Recovery context is provider-neutral +- **WHEN** the callback receives failure details, tools, messages, and metadata +- **THEN** those values SHALL use AI Foundation public DTOs and provider-neutral maps +- **AND** the public API SHALL NOT expose Spring AI message, prompt, response, or exception types diff --git a/openspec/changes/add-agent-runtime/specs/consumer-sdk-documentation/spec.md b/openspec/changes/add-agent-runtime/specs/consumer-sdk-documentation/spec.md new file mode 100644 index 00000000..78a60fdb --- /dev/null +++ b/openspec/changes/add-agent-runtime/specs/consumer-sdk-documentation/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Consumer documentation covers the complete agent workflow +The consumer SDK documentation SHALL provide a dedicated caller-oriented guide for constructing, calling, streaming, and serving agents. + +#### Scenario: Consumer reads agent construction guidance +- **WHEN** a plugin author opens the agent guide +- **THEN** it SHALL show how to resolve a language model, define immutable instructions and tools, configure output and bounded steps, and build an agent using public API types + +#### Scenario: Consumer reads call guidance +- **WHEN** a plugin author needs request-specific behavior +- **THEN** the guide SHALL explain prompt versus messages, typed call options, validation, asynchronous call preparation, metadata, context, cancellation, timeout, lifecycle, and middleware precedence + +#### Scenario: Consumer reads result guidance +- **WHEN** a plugin author executes generate or stream +- **THEN** the guide SHALL explain existing result projections, steps, usage, warnings, response messages, structured output, tool outcomes, and UI Message conversion + +### Requirement: Consumer documentation covers agent tools and recovery +The documentation SHALL explain the complete tool lifecycle within agents, including safe recovery boundaries. + +#### Scenario: Consumer configures tool behavior +- **WHEN** a plugin author adds server, external, or approval-required tools +- **THEN** the guide SHALL explain input lifecycle, execution or handoff, response-message continuation, automatic frontend continuation, and stop-policy interaction + +#### Scenario: Consumer configures recovery +- **WHEN** a plugin author wants to recover invalid input or a renamed tool +- **THEN** the guide SHALL explain failure kinds, available-tool context, stable call ids, full revalidation, warnings, and safe fallback + +#### Scenario: Consumer distinguishes non-recoverable failures +- **WHEN** a plugin author reads recovery guidance +- **THEN** it SHALL state that denial, executor failure, output validation, timeout, and cancellation are not tool-call recovery inputs + +### Requirement: Consumer documentation covers Java UI Message integration +The documentation SHALL show how to expose an agent through the existing Java UI Message chat handler and consume it with the existing browser SDK. + +#### Scenario: Consumer implements an agent chat endpoint +- **WHEN** a plugin author follows the endpoint example +- **THEN** it SHALL validate transport input, derive typed call options, execute the agent handler, apply cancellation, and write the existing SSE response correctly + +#### Scenario: Consumer implements the browser client +- **WHEN** a plugin author follows the frontend example +- **THEN** it SHALL use the existing `Chat` or `useChat`, transport, tool actions, persistence validation, and stream reducer without an agent-specific protocol + +### Requirement: Consumer documentation states agent ownership boundaries +The agent guide SHALL distinguish stateless call orchestration from durable business runtimes. + +#### Scenario: Consumer evaluates persistence +- **WHEN** a plugin author needs stored conversations, resumable runs, scheduling, memory, or restart recovery +- **THEN** the guide SHALL state that the consuming plugin owns those concerns +- **AND** it SHALL show that persisted response messages can be supplied to a later agent call without claiming that the agent stores them + +#### Scenario: Consumer evaluates tools +- **WHEN** a plugin author needs browser, editor, content, network, or domain tools +- **THEN** the guide SHALL state that the consuming plugin defines and authorizes those tools + +### Requirement: Agent documentation examples are verified +Agent documentation SHALL remain aligned with the published API and executable behavior. + +#### Scenario: Documentation quality gates run +- **WHEN** the change is validated +- **THEN** referenced public types and methods SHALL be covered by compile-shape or source-link checks +- **AND** Chinese and English navigation and API reference entries SHALL be updated together diff --git a/openspec/changes/add-agent-runtime/specs/model-test-workbench/spec.md b/openspec/changes/add-agent-runtime/specs/model-test-workbench/spec.md new file mode 100644 index 00000000..472d0c2b --- /dev/null +++ b/openspec/changes/add-agent-runtime/specs/model-test-workbench/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: Workbench provides complete agent runtime testing +The administrator model test workbench SHALL provide an agent mode that exercises the published agent runtime end to end instead of reimplementing agent behavior in console-only code. + +#### Scenario: Administrator selects agent mode +- **WHEN** an administrator selects an enabled language model and enables agent mode +- **THEN** the backend SHALL construct the published agent definition over that resolved model +- **AND** the frontend SHALL continue using the generated endpoint client and public browser chat runtime + +#### Scenario: Agent mode exposes its effective policy +- **WHEN** agent mode is active +- **THEN** the workbench SHALL display the effective instructions, maximum step count, active tools, output mode, call-option profile, and enabled recovery or approval diagnostics + +### Requirement: Workbench covers agent call preparation and step execution +The agent workbench SHALL expose deterministic controls that demonstrate one-time call preparation and per-step preparation as different phases. + +#### Scenario: Typed call option changes preparation +- **WHEN** the administrator selects a documented call-option value and submits a message +- **THEN** the backend SHALL validate that typed option and use the public call-preparation hook to change a visible semantic request setting +- **AND** diagnostics SHALL show that preparation ran once + +#### Scenario: Step preparation changes active tools +- **WHEN** the configured agent reaches a later model step +- **THEN** the public step-preparation callback SHALL apply the configured active-tool change for that step +- **AND** diagnostics SHALL distinguish it from call preparation + +#### Scenario: Default bound is visible +- **WHEN** the workbench uses the default agent stop policy +- **THEN** it SHALL display the maximum of 20 model steps and the actual completed step count + +### Requirement: Workbench covers the full tool lifecycle +The agent workbench SHALL exercise server tools, external tools, approval, invalid input, unknown-tool recovery, execution result or error, and automatic continuation through the public runtime. + +#### Scenario: Server tool executes and continues +- **WHEN** the model calls the enabled server test tool with valid input +- **THEN** the workbench SHALL display input lifecycle, execution, result, response-message continuation, and the following model step + +#### Scenario: External tool is completed by the browser +- **WHEN** the model calls the enabled external test tool +- **THEN** the browser SHALL provide the result through the public chat action +- **AND** the agent SHALL continue only according to the configured automatic-send predicate and step bound + +#### Scenario: Tool requires approval +- **WHEN** the model calls an approval-required test tool +- **THEN** the workbench SHALL display approve and reject actions +- **AND** the resulting continuation SHALL preserve the original call id and approval semantics + +#### Scenario: Invalid input is recovered +- **WHEN** the model produces invalid input for the known recovery test tool +- **THEN** the workbench SHALL display the original failure, successful recovery warning, validated input, and final tool outcome without mixing diagnostics into answer text + +#### Scenario: Unknown name is recovered +- **WHEN** the agent runtime receives the configured deprecated test-tool name +- **THEN** the public recovery callback SHALL map it to the current available test tool +- **AND** the workbench SHALL display original and resolved names, one stable call id, and the final outcome + +#### Scenario: Recovery fails +- **WHEN** recovery is disabled or returns an invalid target +- **THEN** the workbench SHALL display the safe tool error and failed-recovery warning +- **AND** it SHALL NOT show a tool execution result + +### Requirement: Workbench covers structured output and operational controls +The agent workbench SHALL exercise structured output, cancellation, stream termination, warnings, and final result aggregation in agent mode. + +#### Scenario: Agent produces structured output +- **WHEN** the administrator selects a structured output fixture and sends a valid request +- **THEN** the agent stream SHALL expose partial output and a validated final output +- **AND** the workbench SHALL display the final value separately from answer text diagnostics + +#### Scenario: Administrator cancels an agent stream +- **WHEN** the administrator stops an active agent stream +- **THEN** provider and tool work SHALL be cancelled through the shared token +- **AND** the workbench SHALL reach the existing non-error aborted terminal state + +#### Scenario: Agent call finishes +- **WHEN** agent execution completes normally or with a terminal error +- **THEN** the workbench SHALL display finish reason, completed steps, total usage, warnings, response messages, and terminal stream state diff --git a/openspec/changes/add-agent-runtime/specs/sdk-ergonomics/spec.md b/openspec/changes/add-agent-runtime/specs/sdk-ergonomics/spec.md new file mode 100644 index 00000000..1b292a6b --- /dev/null +++ b/openspec/changes/add-agent-runtime/specs/sdk-ergonomics/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Agent API is discoverable and cohesive +The published SDK SHALL expose the complete agent construction and execution path from one cohesive package without requiring implementation-module imports. + +#### Scenario: Consumer discovers agent types +- **WHEN** a consumer browses `run.halo.aifoundation.agent` +- **THEN** agent definition, call, validation, preparation, and prepared-call types SHALL be named consistently and documented from that package + +#### Scenario: Consumer constructs an agent +- **WHEN** a consumer uses the preferred public builder or factory +- **THEN** the construction path SHALL require a model and SHALL make stable settings, typed call options, and generate/stream entry points discoverable through the IDE + +### Requirement: Agent API is immutable in ordinary use +The preferred public API SHALL prevent shared agent policy from being changed after construction. + +#### Scenario: Caller mutates input collections +- **WHEN** a caller changes tools, middleware, headers, or other collections supplied during construction +- **THEN** the built agent SHALL retain its original values + +#### Scenario: Caller inspects agent state +- **WHEN** public accessors return agent definition values +- **THEN** collections and nested values SHALL be immutable views or defensive copies + +### Requirement: Agent API supports typed no-options and custom-options forms +The SDK SHALL provide ergonomic construction for both ordinary agents and agents with business-specific call options. + +#### Scenario: Agent needs no custom options +- **WHEN** a consumer defines an ordinary prompt or message agent +- **THEN** the preferred API SHALL not require raw maps, unchecked casts, or placeholder option objects + +#### Scenario: Agent uses custom typed options +- **WHEN** a consumer defines a call-options DTO and validator +- **THEN** call preparation SHALL receive that DTO with compile-time type information +- **AND** UI endpoint code SHALL be able to pass the same type without an unchecked public conversion + +### Requirement: Agent API quality gates cover public boundaries +Static and automated quality gates SHALL protect the new public API from implementation leakage and partial delivery. + +#### Scenario: Public API architecture test runs +- **WHEN** backend tests run +- **THEN** agent public signatures SHALL contain no Spring AI implementation types and no app-module-only types + +#### Scenario: API construction tests run +- **WHEN** SDK ergonomics tests run +- **THEN** they SHALL compile representative agent definition, typed preparation, generate, stream, recovery, cancellation, and UI Message examples + +#### Scenario: Complete delivery is checked +- **WHEN** the change is considered complete +- **THEN** public API, runtime implementation, UI handler, workbench, tests, and documentation tasks SHALL all be complete +- **AND** no layer SHALL be deferred as a follow-up for the same agent capability diff --git a/openspec/changes/add-agent-runtime/specs/ui-message-stream/spec.md b/openspec/changes/add-agent-runtime/specs/ui-message-stream/spec.md new file mode 100644 index 00000000..1da895d5 --- /dev/null +++ b/openspec/changes/add-agent-runtime/specs/ui-message-stream/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Java UI Message chat handlers execute agents +The Java UI Message chat handler SHALL provide an agent entry point that reuses the existing chat request, validation, conversion, cancellation, stream aggregation, callback, and response contracts. + +#### Scenario: Agent handles a submit request +- **WHEN** a consumer supplies an agent, a `submit-message` chat request, and typed call options +- **THEN** the handler SHALL validate and convert the effective UI messages +- **AND** it SHALL execute the agent with the converted model messages and typed options + +#### Scenario: Agent handles a regenerate request +- **WHEN** a consumer supplies an agent and a valid `regenerate-message` chat request +- **THEN** the handler SHALL remove the target assistant response using the existing trigger semantics +- **AND** it SHALL execute the agent from the resulting validated conversation + +#### Scenario: Agent and model execution are exclusive +- **WHEN** handler configuration selects both direct model execution and agent execution or selects neither +- **THEN** the handler SHALL reject the configuration before stream creation + +### Requirement: Agent UI execution preserves preparation boundaries +The UI Message handler SHALL convert persisted messages before agent call preparation and SHALL keep agent-owned policy separate from transport customization. + +#### Scenario: Validated messages reach call preparation +- **WHEN** UI message validation and conversion succeed +- **THEN** agent call preparation SHALL receive the converted provider-neutral model messages +- **AND** it SHALL NOT receive unvalidated UI parts as model input + +#### Scenario: Endpoint supplies typed options +- **WHEN** the endpoint derives typed agent options from authenticated request state, path data, or a validated body +- **THEN** the handler SHALL pass those options to the agent call unchanged +- **AND** it SHALL NOT serialize the options into model messages automatically + +#### Scenario: Transport configuration cannot replace agent policy +- **WHEN** an agent entry point is used +- **THEN** model-request customizers that would directly replace agent instructions, tools, output, or stop policy SHALL be rejected or unavailable +- **AND** the agent call preparation contract SHALL remain the policy-owned customization point + +### Requirement: Agent UI execution preserves canonical stream behavior +Agent chat responses SHALL use the same UI Message stream version and terminal semantics as direct model chat responses. + +#### Scenario: Agent emits multi-step content +- **WHEN** an agent stream emits text, reasoning, sources, files, steps, tools, approvals, results, warnings, finish, or error parts +- **THEN** the handler SHALL map them through the existing chunk types and reducer identities + +#### Scenario: Handler cancellation stops the agent +- **WHEN** a subscriber cancels or the configured UI Message cancellation token is cancelled +- **THEN** the same token SHALL cancel the agent call and its underlying model or tool work +- **AND** the stream SHALL use the existing abort and finish semantics + +#### Scenario: Finish callback observes agent output +- **WHEN** an agent stream completes +- **THEN** the existing finish callback SHALL receive the complete conversation, response message, and terminal state + +#### Scenario: Browser client remains unchanged +- **WHEN** the Vue chat runtime consumes an agent response +- **THEN** it SHALL use the existing transport, protocol header, chunk validator, reducer, persistence format, and chat actions +- **AND** no agent-specific browser transport or message part SHALL be required diff --git a/openspec/changes/add-agent-runtime/tasks.md b/openspec/changes/add-agent-runtime/tasks.md new file mode 100644 index 00000000..7a181380 --- /dev/null +++ b/openspec/changes/add-agent-runtime/tasks.md @@ -0,0 +1,62 @@ +## 1. Public Agent And Recovery Contracts + +- [x] 1.1 Add the cohesive `run.halo.aifoundation.agent` package with immutable agent definition, typed call, validation, asynchronous preparation, preparation context, and effective prepared-call contracts. +- [x] 1.2 Expose every stable semantic agent setting through the preferred builder or factory, defensively copy mutable inputs, and provide ergonomic no-options and typed-options construction paths. +- [x] 1.3 Add provider-neutral tool-call failure kinds and expand recovery context with matching tool, available tools, step state, messages, request context, and metadata. +- [x] 1.4 Add SDK construction and architecture tests proving the public agent and recovery signatures compile from the API module without Spring AI or app-module types. + +## 2. Agent Request Composition And Execution + +- [x] 2.1 Implement fresh per-call request composition with the specified definition, call input, operational control, middleware/lifecycle, call-preparation, and step-preparation precedence. +- [x] 2.2 Implement typed call-option validation and one-time asynchronous call preparation, including current-call model replacement and pre-provider failure reporting. +- [x] 2.3 Apply the bounded 20-step agent default while preserving custom stop conditions and the existing direct `LanguageModel` single-step default. +- [x] 2.4 Implement agent generate and stream delegation over `LanguageModel` without duplicating the model loop or result hierarchy. +- [x] 2.5 Preserve structured output, tools, approvals, external continuation, middleware, lifecycle, retries, warnings, cancellation, and timeout controls in the composed request. +- [x] 2.6 Add deterministic tests for validation and preparation ordering, precedence conflicts, preparation failure, default and custom stop policies, and non-streaming/streaming terminal-result parity. +- [x] 2.7 Add concurrency, defensive-copy, cancellation-isolation, and multi-view stream tests proving one reusable agent does not share mutable invocation state or repeat side effects. + +## 3. Complete Tool-Call Recovery + +- [x] 3.1 Refactor common tool resolution so invalid known-tool input and unknown tool names enter one typed recovery path in both non-streaming and streaming execution. +- [x] 3.2 Allow unknown-tool recovery to select only a currently available tool while preserving the original call id, then rerun schema, approval, external-tool, and executor validation. +- [x] 3.3 Preserve safe original errors and emit stable success or failure warnings for absent, throwing, id-changing, unavailable-name, or still-invalid recovery results. +- [x] 3.4 Implement streamed unknown-tool accumulation and resolved-tool input callback replay in canonical start, delta, and available order without read-ahead or duplicate execution. +- [x] 3.5 Update tool stream parts and UI Message reduction to finalize a recovered tool name on the existing call-id identity instead of creating a second persistent tool part. +- [x] 3.6 Add focused generate and stream tests for successful invalid-input recovery, renamed-tool recovery, no available target, every rejected recovery shape, interleaved calls, callback failure, approval, external handoff, and executor continuation. + +## 4. Java UI Message Agent Integration + +- [x] 4.1 Extract a shared internal UI Message execution pipeline while preserving all existing direct-model handler behavior and tests. +- [x] 4.2 Add typed agent handler entry points for submit and regenerate triggers, converted model messages, endpoint-owned call options, and mutually exclusive model or agent execution. +- [x] 4.3 Compose UI cancellation, validation, conversion, metadata, serializer, finish/error callbacks, and reasoning policy into the agent call without exposing direct agent-policy replacement through transport options. +- [x] 4.4 Add Java UI Message tests for agent submit, regenerate, validation failure, preparation failure, multi-step tools, recovered names, structured output, cancellation, finish aggregation, and unchanged wire chunks. + +## 5. Workbench Backend Coverage + +- [x] 5.1 Extend the console model-test request contract with agent mode, typed call-option profile, step-policy, tool lifecycle, recovery, approval, external-tool, and structured-output diagnostics. +- [x] 5.2 Regenerate the OpenAPI browser client after backend request or response changes and use only generated endpoint bindings in the console UI. +- [x] 5.3 Implement the workbench agent endpoint path by constructing and executing the published agent API over the selected configured model; do not add a console-only agent engine. +- [x] 5.4 Add deterministic console test tools and diagnostics for call preparation, step preparation, server execution, browser completion, approval, invalid input, renamed tool recovery, failed recovery, and stable call identity. +- [x] 5.5 Add backend endpoint tests for authorization boundary, model resolution, all agent mode fields, request validation, stream response, cancellation, and safe diagnostic serialization. + +## 6. Complete Workbench User Experience + +- [x] 6.1 Add an agent mode to the existing workbench with controls for typed call preparation, effective instructions, maximum and completed steps, active tools, output mode, approval, external tools, and recovery scenarios. +- [x] 6.2 Render agent step lifecycle, tool input, original and resolved tool names, stable call id, approval, result/error, warnings, usage, response messages, and terminal state outside assistant answer text. +- [x] 6.3 Reuse the public browser `Chat` or `useChat`, generated client, existing tool actions, automatic-send predicate, stream reducer, cancellation, and persistence validation without adding an agent transport. +- [x] 6.4 Add frontend tests for request assembly, mode switching, call-option controls, tool and approval actions, recovery diagnostics, structured output, cancellation, and terminal-state rendering. + +## 7. Consumer Documentation And API Reference + +- [x] 7.1 Add Chinese and English SDK Core agent guides covering model resolution, immutable definition, typed call validation, preparation, bounded steps, generate/stream results, lifecycle, and request isolation. +- [x] 7.2 Document server, external, and approval-required tools plus invalid-input and renamed-tool recovery, full revalidation, stable call identity, warnings, and non-recoverable failure boundaries. +- [x] 7.3 Add Java UI Message endpoint and existing browser chat examples with typed call options, cancellation, persistence, and response-message continuation. +- [x] 7.4 Document that consumers own durable conversations, resumable runs, scheduling, memory, authorization, and business tools, and explain the boundary without implying built-in persistence. +- [x] 7.5 Update Chinese and English SDK navigation, package/API references, integration skill references, and compile-shape or source-link documentation checks together. + +## 8. End-To-End Verification And Completion Gate + +- [x] 8.1 Run API and backend focused tests for agent construction, runtime, recovery, UI Message integration, and console endpoints, then run the full Gradle test suite. +- [x] 8.2 Run frontend package tests, console component tests, type checking, linting, API-client drift checks, and production build with the repository-declared package manager. +- [ ] 8.3 Restart the Halo development runtime and manually verify prompt and message calls, multiple steps, server and external tools, approval, both recovery kinds, structured output, cancellation, and final diagnostics in the workbench. +- [ ] 8.4 Run strict OpenSpec validation, documentation quality gates, public API architecture checks, and `git diff --check`, then confirm every task in this change is complete before requesting archive or delivery. diff --git a/skills/use-ai-foundation-sdk/SKILL.md b/skills/use-ai-foundation-sdk/SKILL.md index 559882d2..92f18602 100644 --- a/skills/use-ai-foundation-sdk/SKILL.md +++ b/skills/use-ai-foundation-sdk/SKILL.md @@ -1,6 +1,6 @@ --- name: use-ai-foundation-sdk -description: Query, explain, integrate, and debug the Halo AI Foundation Java SDK, browser/Vue SDK, UI Message transport, and FormKit model selector. Use when an AI coding agent needs to answer AI Foundation API questions, add AI capabilities to a Halo plugin, implement text generation, structured output, tools, embeddings, reranking, RAG, image generation, streaming chat, message persistence, or model selection, or verify a consumer plugin against the official SDK contracts. +description: Query, explain, integrate, and debug the Halo AI Foundation Java SDK, agent runtime, browser/Vue SDK, UI Message transport, and FormKit model selector. Use when an AI coding agent needs to answer AI Foundation API questions, add AI capabilities to a Halo plugin, implement agents, text generation, structured output, tools, embeddings, reranking, RAG, image generation, streaming chat, message persistence, or model selection, or verify a consumer plugin against the official SDK contracts. --- # Use AI Foundation SDK @@ -95,6 +95,7 @@ the relevant documentation and public source. 1. Identify the requested surface: - Java backend SDK Core. + - Java Agent runtime and typed UI Message endpoint. - Browser or Vue SDK UI. - UI Message backend-to-frontend transport. - FormKit `aiModelSelector`. @@ -129,6 +130,8 @@ Apply these defaults unless the target plugin establishes a stronger convention: - Use `ExtensionGetter.getEnabledExtension(AiModelService.class)` across plugin `ApplicationContext` boundaries. - Store and pass `AiModel.metadata.name` as `modelName`. +- Build reusable agent policy with `AgentOptions`; keep per-call input and operational controls in + `AgentCall`. - Resolve the appropriate `LanguageModel`, `EmbeddingModel`, `RerankingModel`, or `ImageGenerationModel` through `AiModelService`. - Register beans that reference AI Foundation types only when AI Foundation is available if the @@ -143,6 +146,10 @@ Apply these defaults unless the target plugin establishes a stronger convention: - Keep Reactor composition non-blocking. Do not call `block()` in request paths. - Use either `prompt` or `messages`; combine either with `system` when needed. - Preserve `responseMessages` for tool loops or continued model context. +- Treat `Agent` as one-call orchestration, not durable run, checkpoint, scheduler, or memory + storage. The consumer owns persistence and resume policy. +- Keep a recovered tool call id stable and rely on full runtime revalidation after invalid-input + or renamed-tool recovery. - Consume `StreamTextResult` projections according to the caller's need: `textStream()`, `fullStream()`, `partialOutputStream()`, `elementStream()`, `output()`, or `result()`. diff --git a/skills/use-ai-foundation-sdk/references/sdk-map.md b/skills/use-ai-foundation-sdk/references/sdk-map.md index 5e7a71a1..b8e244a6 100644 --- a/skills/use-ai-foundation-sdk/references/sdk-map.md +++ b/skills/use-ai-foundation-sdk/references/sdk-map.md @@ -28,6 +28,7 @@ workspace is an AI Foundation source checkout, resolve the same paths from its r | Generate or stream text | `dev/{locale}/sdk-core/generating-text.md` | | Generate typed JSON, arrays, or choices | `dev/{locale}/sdk-core/generating-structured-data.md` | | Define tools, approvals, repair, or multiple steps | `dev/{locale}/sdk-core/tools-and-tool-calling.md` | +| Build a reusable agent or typed UI Message endpoint | `dev/{locale}/sdk-core/agents.md` | | Embed, rerank, or compose RAG | `dev/{locale}/sdk-core/embeddings-reranking-and-rag.md` | | Generate or edit images | `dev/{locale}/sdk-core/image-generation.md` | | Add middleware, lifecycle, cancellation, or timeouts | `dev/{locale}/sdk-core/middleware-and-lifecycle.md` | @@ -50,6 +51,7 @@ workspace is an AI Foundation source checkout, resolve the same paths from its r | ------------------------ | ------------------------------------------------------------- | | Model discovery | `api/src/main/java/run/halo/aifoundation/AiModelService.java` | | Text request and result | `api/src/main/java/run/halo/aifoundation/chat/` | +| Agent runtime | `api/src/main/java/run/halo/aifoundation/agent/` | | Model messages and parts | `api/src/main/java/run/halo/aifoundation/message/` | | Stream parts | `api/src/main/java/run/halo/aifoundation/part/` | | Structured output | `api/src/main/java/run/halo/aifoundation/schema/` | diff --git a/ui/packages/sdk/src/core.test.ts b/ui/packages/sdk/src/core.test.ts index 87a813e7..497a2435 100644 --- a/ui/packages/sdk/src/core.test.ts +++ b/ui/packages/sdk/src/core.test.ts @@ -193,6 +193,48 @@ describe('UI message reducer', () => { ]) }) + it('finalizes a recovered tool name on the existing call identity', () => { + const state = createUIMessageReducer({ messageId: 'assistant-1' }) + + for (const chunk of [ + { type: 'tool-input-start', toolCallId: 'call-1', toolName: 'legacyWeather' }, + { + type: 'tool-input-delta', + toolCallId: 'call-1', + inputTextDelta: '{"location":"SF"}', + }, + { + type: 'tool-input-available', + toolCallId: 'call-1', + toolName: 'weather', + input: { location: 'SF' }, + providerMetadata: { recovered: true }, + }, + { + type: 'tool-output-available', + toolCallId: 'call-1', + toolName: 'weather', + output: { temperature: 22 }, + }, + ] satisfies UIMessageChunk[]) { + applyUIMessageChunk(state, chunk) + } + + expect(state.message.parts).toEqual([ + { + type: 'tool-weather', + toolCallId: 'call-1', + toolName: 'weather', + state: 'output-available', + input: { location: 'SF' }, + output: { temperature: 22 }, + errorText: undefined, + approval: undefined, + providerMetadata: { recovered: true }, + }, + ]) + }) + it('parses streamed tool input privately and resets duplicate starts', () => { const state = createUIMessageReducer({ messageId: 'assistant-1' }) diff --git a/ui/packages/sdk/src/message-reducer.ts b/ui/packages/sdk/src/message-reducer.ts index 88751981..b48def93 100644 --- a/ui/packages/sdk/src/message-reducer.ts +++ b/ui/packages/sdk/src/message-reducer.ts @@ -393,6 +393,9 @@ function upsertMessagePart( } function samePartIdentity(left: UIMessagePart, right: UIMessagePart): boolean { + if (isToolPart(left) && isToolPart(right)) { + return left.toolCallId === right.toolCallId + } if (left.type !== right.type) { return false } @@ -409,9 +412,6 @@ function samePartIdentity(left: UIMessagePart, right: UIMessagePart): boolean { case 'source-document': return 'sourceId' in left && left.sourceId === right.sourceId default: - if (isToolPart(right)) { - return isToolPart(left) && left.toolCallId === right.toolCallId - } return false } } diff --git a/ui/src/api/generated/.openapi-generator/FILES b/ui/src/api/generated/.openapi-generator/FILES index f4317f17..5993e237 100644 --- a/ui/src/api/generated/.openapi-generator/FILES +++ b/ui/src/api/generated/.openapi-generator/FILES @@ -69,6 +69,7 @@ models/rerank-response-metadata.ts models/rerank-usage.ts models/rerank-warning.ts models/selection.ts +models/test-agent-options.ts models/test-completion-stream-request.ts models/test-embedding-request.ts models/test-embedding-response.ts diff --git a/ui/src/api/generated/api/console-api-aifoundation-halo-run-v1alpha1-model-api.ts b/ui/src/api/generated/api/console-api-aifoundation-halo-run-v1alpha1-model-api.ts index 82e7a153..f2888963 100644 --- a/ui/src/api/generated/api/console-api-aifoundation-halo-run-v1alpha1-model-api.ts +++ b/ui/src/api/generated/api/console-api-aifoundation-halo-run-v1alpha1-model-api.ts @@ -473,10 +473,11 @@ export const ConsoleApiAifoundationHaloRunV1alpha1ModelApiAxiosParamCreator = fu * @param {boolean} [enableExternalTestTool] Whether to inject the console-only halo_external_test_info tool that must be executed by the workbench caller. * @param {boolean} [enableToolCallRepair] Whether to inject a console-only repairable tool and deterministic tool-call repair callback. * @param {boolean} [enableAgentTestTools] Whether to inject console-only browser Agent test tools that are executed by the workbench frontend. + * @param {boolean} [enableToolInputStreamTest] Whether to inject the console-only lifecycle-aware tool for streamed tool-input diagnostics. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - testModelUiMessageChatStream: async (name: string, testUiMessageChatRequest: TestUiMessageChatRequest, enableTestTool?: boolean, enableTestToolApproval?: boolean, enableExternalTestTool?: boolean, enableToolCallRepair?: boolean, enableAgentTestTools?: boolean, options: RawAxiosRequestConfig = {}): Promise => { + testModelUiMessageChatStream: async (name: string, testUiMessageChatRequest: TestUiMessageChatRequest, enableTestTool?: boolean, enableTestToolApproval?: boolean, enableExternalTestTool?: boolean, enableToolCallRepair?: boolean, enableAgentTestTools?: boolean, enableToolInputStreamTest?: boolean, options: RawAxiosRequestConfig = {}): Promise => { // verify required parameter 'name' is not null or undefined assertParamExists('testModelUiMessageChatStream', 'name', name) // verify required parameter 'testUiMessageChatRequest' is not null or undefined @@ -522,6 +523,10 @@ export const ConsoleApiAifoundationHaloRunV1alpha1ModelApiAxiosParamCreator = fu localVarQueryParameter['enableAgentTestTools'] = enableAgentTestTools; } + if (enableToolInputStreamTest !== undefined) { + localVarQueryParameter['enableToolInputStreamTest'] = enableToolInputStreamTest; + } + localVarHeaderParameter['Content-Type'] = 'application/json'; @@ -717,11 +722,12 @@ export const ConsoleApiAifoundationHaloRunV1alpha1ModelApiFp = function(configur * @param {boolean} [enableExternalTestTool] Whether to inject the console-only halo_external_test_info tool that must be executed by the workbench caller. * @param {boolean} [enableToolCallRepair] Whether to inject a console-only repairable tool and deterministic tool-call repair callback. * @param {boolean} [enableAgentTestTools] Whether to inject console-only browser Agent test tools that are executed by the workbench frontend. + * @param {boolean} [enableToolInputStreamTest] Whether to inject the console-only lifecycle-aware tool for streamed tool-input diagnostics. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async testModelUiMessageChatStream(name: string, testUiMessageChatRequest: TestUiMessageChatRequest, enableTestTool?: boolean, enableTestToolApproval?: boolean, enableExternalTestTool?: boolean, enableToolCallRepair?: boolean, enableAgentTestTools?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.testModelUiMessageChatStream(name, testUiMessageChatRequest, enableTestTool, enableTestToolApproval, enableExternalTestTool, enableToolCallRepair, enableAgentTestTools, options); + async testModelUiMessageChatStream(name: string, testUiMessageChatRequest: TestUiMessageChatRequest, enableTestTool?: boolean, enableTestToolApproval?: boolean, enableExternalTestTool?: boolean, enableToolCallRepair?: boolean, enableAgentTestTools?: boolean, enableToolInputStreamTest?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.testModelUiMessageChatStream(name, testUiMessageChatRequest, enableTestTool, enableTestToolApproval, enableExternalTestTool, enableToolCallRepair, enableAgentTestTools, enableToolInputStreamTest, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['ConsoleApiAifoundationHaloRunV1alpha1ModelApi.testModelUiMessageChatStream']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -837,7 +843,7 @@ export const ConsoleApiAifoundationHaloRunV1alpha1ModelApiFactory = function (co * @throws {RequiredError} */ testModelUiMessageChatStream(requestParameters: ConsoleApiAifoundationHaloRunV1alpha1ModelApiTestModelUiMessageChatStreamRequest, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.testModelUiMessageChatStream(requestParameters.name, requestParameters.testUiMessageChatRequest, requestParameters.enableTestTool, requestParameters.enableTestToolApproval, requestParameters.enableExternalTestTool, requestParameters.enableToolCallRepair, requestParameters.enableAgentTestTools, options).then((request) => request(axios, basePath)); + return localVarFp.testModelUiMessageChatStream(requestParameters.name, requestParameters.testUiMessageChatRequest, requestParameters.enableTestTool, requestParameters.enableTestToolApproval, requestParameters.enableExternalTestTool, requestParameters.enableToolCallRepair, requestParameters.enableAgentTestTools, requestParameters.enableToolInputStreamTest, options).then((request) => request(axios, basePath)); }, /** * Update an AI model. @@ -1080,6 +1086,13 @@ export interface ConsoleApiAifoundationHaloRunV1alpha1ModelApiTestModelUiMessage * @memberof ConsoleApiAifoundationHaloRunV1alpha1ModelApiTestModelUiMessageChatStream */ readonly enableAgentTestTools?: boolean + + /** + * Whether to inject the console-only lifecycle-aware tool for streamed tool-input diagnostics. + * @type {boolean} + * @memberof ConsoleApiAifoundationHaloRunV1alpha1ModelApiTestModelUiMessageChatStream + */ + readonly enableToolInputStreamTest?: boolean } /** @@ -1217,7 +1230,7 @@ export class ConsoleApiAifoundationHaloRunV1alpha1ModelApi extends BaseAPI { * @memberof ConsoleApiAifoundationHaloRunV1alpha1ModelApi */ public testModelUiMessageChatStream(requestParameters: ConsoleApiAifoundationHaloRunV1alpha1ModelApiTestModelUiMessageChatStreamRequest, options?: RawAxiosRequestConfig) { - return ConsoleApiAifoundationHaloRunV1alpha1ModelApiFp(this.configuration).testModelUiMessageChatStream(requestParameters.name, requestParameters.testUiMessageChatRequest, requestParameters.enableTestTool, requestParameters.enableTestToolApproval, requestParameters.enableExternalTestTool, requestParameters.enableToolCallRepair, requestParameters.enableAgentTestTools, options).then((request) => request(this.axios, this.basePath)); + return ConsoleApiAifoundationHaloRunV1alpha1ModelApiFp(this.configuration).testModelUiMessageChatStream(requestParameters.name, requestParameters.testUiMessageChatRequest, requestParameters.enableTestTool, requestParameters.enableTestToolApproval, requestParameters.enableExternalTestTool, requestParameters.enableToolCallRepair, requestParameters.enableAgentTestTools, requestParameters.enableToolInputStreamTest, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/ui/src/api/generated/base.ts b/ui/src/api/generated/base.ts index 176dff50..90e2b9e1 100644 --- a/ui/src/api/generated/base.ts +++ b/ui/src/api/generated/base.ts @@ -19,7 +19,7 @@ import type { Configuration } from './configuration'; import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; import globalAxios from 'axios'; -export const BASE_PATH = "http://localhost:55124".replace(/\/+$/, ""); +export const BASE_PATH = "http://localhost:33894".replace(/\/+$/, ""); /** * diff --git a/ui/src/api/generated/models/index.ts b/ui/src/api/generated/models/index.ts index 5f8cbdd8..fc9d3848 100644 --- a/ui/src/api/generated/models/index.ts +++ b/ui/src/api/generated/models/index.ts @@ -51,6 +51,7 @@ export * from './rerank-response-metadata'; export * from './rerank-usage'; export * from './rerank-warning'; export * from './selection'; +export * from './test-agent-options'; export * from './test-completion-stream-request'; export * from './test-embedding-request'; export * from './test-embedding-response'; diff --git a/ui/src/api/generated/models/model-parameter-definition-info.ts b/ui/src/api/generated/models/model-parameter-definition-info.ts index 2f722dd4..775444be 100644 --- a/ui/src/api/generated/models/model-parameter-definition-info.ts +++ b/ui/src/api/generated/models/model-parameter-definition-info.ts @@ -4,7 +4,7 @@ * Halo * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * - * The version of the OpenAPI document: 2.25.2 + * The version of the OpenAPI document: 2.25.4 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/ui/src/api/generated/models/provider-type-info.ts b/ui/src/api/generated/models/provider-type-info.ts index f086e58f..d1d3497c 100644 --- a/ui/src/api/generated/models/provider-type-info.ts +++ b/ui/src/api/generated/models/provider-type-info.ts @@ -4,7 +4,7 @@ * Halo * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) * - * The version of the OpenAPI document: 2.25.2 + * The version of the OpenAPI document: 2.25.4 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/ui/src/api/generated/models/test-agent-options.ts b/ui/src/api/generated/models/test-agent-options.ts new file mode 100644 index 00000000..14d47735 --- /dev/null +++ b/ui/src/api/generated/models/test-agent-options.ts @@ -0,0 +1,108 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.25.4 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Agent workbench execution and diagnostic options. + * @export + * @interface TestAgentOptions + */ +export interface TestAgentOptions { + /** + * + * @type {boolean} + * @memberof TestAgentOptions + */ + 'approvalRequired'?: boolean; + /** + * + * @type {boolean} + * @memberof TestAgentOptions + */ + 'browserToolEnabled'?: boolean; + /** + * + * @type {boolean} + * @memberof TestAgentOptions + */ + 'enabled'?: boolean; + /** + * + * @type {boolean} + * @memberof TestAgentOptions + */ + 'externalToolEnabled'?: boolean; + /** + * + * @type {number} + * @memberof TestAgentOptions + */ + 'maxSteps'?: number; + /** + * + * @type {string} + * @memberof TestAgentOptions + */ + 'profile'?: TestAgentOptionsProfileEnum; + /** + * + * @type {string} + * @memberof TestAgentOptions + */ + 'recoveryScenario'?: TestAgentOptionsRecoveryScenarioEnum; + /** + * + * @type {boolean} + * @memberof TestAgentOptions + */ + 'serverToolEnabled'?: boolean; + /** + * + * @type {string} + * @memberof TestAgentOptions + */ + 'stepPolicy'?: TestAgentOptionsStepPolicyEnum; + /** + * + * @type {boolean} + * @memberof TestAgentOptions + */ + 'toolInputStreamEnabled'?: boolean; +} + +export const TestAgentOptionsProfileEnum = { + Balanced: 'BALANCED', + Concise: 'CONCISE', + Explicit: 'EXPLICIT' +} as const; + +export type TestAgentOptionsProfileEnum = typeof TestAgentOptionsProfileEnum[keyof typeof TestAgentOptionsProfileEnum]; +export const TestAgentOptionsRecoveryScenarioEnum = { + None: 'NONE', + InvalidInput: 'INVALID_INPUT', + RenamedTool: 'RENAMED_TOOL', + FailedRecovery: 'FAILED_RECOVERY' +} as const; + +export type TestAgentOptionsRecoveryScenarioEnum = typeof TestAgentOptionsRecoveryScenarioEnum[keyof typeof TestAgentOptionsRecoveryScenarioEnum]; +export const TestAgentOptionsStepPolicyEnum = { + AllTools: 'ALL_TOOLS', + ServerThenAll: 'SERVER_THEN_ALL', + ServerThenBrowser: 'SERVER_THEN_BROWSER' +} as const; + +export type TestAgentOptionsStepPolicyEnum = typeof TestAgentOptionsStepPolicyEnum[keyof typeof TestAgentOptionsStepPolicyEnum]; + + diff --git a/ui/src/api/generated/models/test-ui-message-chat-request.ts b/ui/src/api/generated/models/test-ui-message-chat-request.ts index 25b45c86..8223b83c 100644 --- a/ui/src/api/generated/models/test-ui-message-chat-request.ts +++ b/ui/src/api/generated/models/test-ui-message-chat-request.ts @@ -21,6 +21,9 @@ import type { OutputSpec } from './output-spec'; import type { ReasoningOptions } from './reasoning-options'; // May contain unused imports in some cases // @ts-ignore +import type { TestAgentOptions } from './test-agent-options'; +// May contain unused imports in some cases +// @ts-ignore import type { TestUiMessage } from './test-ui-message'; // May contain unused imports in some cases // @ts-ignore @@ -32,6 +35,12 @@ import type { ToolChoice } from './tool-choice'; * @interface TestUiMessageChatRequest */ export interface TestUiMessageChatRequest { + /** + * + * @type {TestAgentOptions} + * @memberof TestUiMessageChatRequest + */ + 'agent'?: TestAgentOptions; /** * * @type {{ [key: string]: object; }} diff --git a/ui/src/composables/workbench/use-chat-workbench.ts b/ui/src/composables/workbench/use-chat-workbench.ts index 574cdb5f..0e4a7f0d 100644 --- a/ui/src/composables/workbench/use-chat-workbench.ts +++ b/ui/src/composables/workbench/use-chat-workbench.ts @@ -1,14 +1,17 @@ -import type { ModelOption } from '@/api/generated' +import type { ModelOption, TestUiMessageChatRequest } from '@/api/generated' import type { useLanguageGenerationSettings } from '@/composables/workbench/use-language-generation-settings' import type { WorkbenchTestMode } from '@/composables/workbench/use-workbench-models' import { applyWorkbenchUIMessageSnapshot, createAssistantUIMessage, createUserUIMessage, + finalizeAgentRunDiagnostics, + recordAgentRunChunk, testUiMessageChatStreamUrl, workbenchDataPartSchemas, workbenchMessageMetadataSchema, type ExamplePrompt, + type UIMessageChunk, type UIMessagePart, type WorkbenchMessage, type WorkbenchWarning, @@ -58,23 +61,66 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { transport: new ObservingChatTransport( { api: '', - prepareSendMessagesRequest: ({ body }) => { + prepareSendMessagesRequest: async ({ body }) => { if (!activeModelName) throw new Error('未选择模型') return { - api: testUiMessageChatStreamUrl(activeModelName, settings.streamOptions()), + api: await testUiMessageChatStreamUrl( + activeModelName, + body as TestUiMessageChatRequest, + settings.streamOptions(), + ), body, } }, }, - (chunk) => recordActiveToolInputChunk(chunk), + (chunk) => { + recordActiveToolInputChunk(chunk) + recordActiveAgentChunk(chunk) + }, ), generateId: () => activeWorkbenchId || utils.id.uuid(), - sendAutomaticallyWhen: lastAssistantMessageHasCompletedToolContinuations, - maxAutomaticSteps: 5, + sendAutomaticallyWhen: async ({ messages: nextMessages }) => { + if (!(await lastAssistantMessageHasCompletedToolContinuations({ messages: nextMessages }))) { + return false + } + if (!settings.agentModeEnabled.value) return true + const latestAssistant = [...nextMessages] + .reverse() + .find((message) => message.role === 'assistant') + if ( + latestAssistant?.parts.some( + (part) => part.type === 'text' && 'text' in part && String(part.text || '').trim(), + ) + ) { + return false + } + let lastUserIndex = -1 + for (let index = nextMessages.length - 1; index >= 0; index--) { + if (nextMessages[index]?.role === 'user') { + lastUserIndex = index + break + } + } + const completedToolContinuations = nextMessages + .slice(lastUserIndex + 1) + .flatMap((message) => message.parts) + .filter( + (part) => + part.type.startsWith('tool-') && + 'state' in part && + ['output-available', 'output-error', 'output-denied'].includes(String(part.state)), + ).length + return completedToolContinuations < Math.max(1, settings.agentMaxSteps.value) + }, + maxAutomaticSteps: 20, messageMetadataSchema: workbenchMessageMetadataSchema, dataPartSchemas: workbenchDataPartSchemas, onToolCall: (part) => { - if (!settings.agentTestToolsEnabled.value || !isWorkbenchAgentTool(part)) return + if ( + !(settings.agentTestToolsEnabled.value || settings.agentBrowserToolEnabled.value) || + !isWorkbenchAgentTool(part) + ) + return const context = { selectedModel: selectedModel.value, testMode: testMode.value, @@ -128,6 +174,16 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { ) } + function recordActiveAgentChunk(chunk: Parameters[1]) { + if (!activeWorkbenchId) return + const message = messages.value.find((item) => item.id === activeWorkbenchId) + if (!message) return + message.agentDiagnostics = recordAgentRunChunk( + message.agentDiagnostics, + chunk as unknown as UIMessageChunk, + ) + } + async function sendMessage(content?: string) { const text = (content ?? input.value).trim() const files = [...chatFiles.value] @@ -164,6 +220,7 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { assistantMessage.uiMessage?.id || assistantMessage.id, ) resetAssistantMessage(assistantMessage) + initializeAgentDiagnostics(assistantMessage, parameters) if (!targetMessage) messages.value.push(assistantMessage) activeModelName = modelName @@ -210,6 +267,7 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { uiChat.setMessages(workbenchMessagesToHalo(messages.value)) targetMessage.uiMessage = createAssistantUIMessage(messageId) resetAssistantMessage(targetMessage) + initializeAgentDiagnostics(targetMessage, parameters) activeModelName = model.name activeWorkbenchId = targetMessage.id activeParameters = parameters @@ -236,6 +294,7 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { message.reasoningState = undefined message.toolEvents = undefined message.toolInputStreamDiagnostics = undefined + message.agentDiagnostics = undefined message.transientData = undefined message.warnings = undefined message.state = 'streaming' @@ -430,9 +489,41 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { const message = messages.value.find((item) => item.id === messageId) if (message?.state !== 'streaming') return message.state = state + if (message.agentDiagnostics) { + message.agentDiagnostics.terminalState = state + message.agentDiagnostics = finalizeAgentRunDiagnostics( + message.agentDiagnostics, + message.content, + ) + } if (message.reasoningState === 'streaming') message.reasoningState = 'done' } + function initializeAgentDiagnostics( + message: WorkbenchMessage, + parameters: ReturnType, + ) { + if (!parameters.agent.enabled) return + message.agentDiagnostics = { + enabled: true, + profile: parameters.agent.profile, + maximumSteps: parameters.agent.maxSteps, + stepPolicy: parameters.agent.stepPolicy, + activeTools: [], + outputMode: parameters.output?.type || 'TEXT', + approvalRequired: parameters.agent.approvalRequired, + externalToolEnabled: parameters.agent.externalToolEnabled, + browserToolEnabled: parameters.agent.browserToolEnabled, + recoveryScenario: parameters.agent.recoveryScenario, + callPreparationCount: 0, + completedSteps: 0, + stepPreparation: [], + steps: [], + tools: [], + warnings: [], + } + } + function parseExternalToolResult(value: string): { value?: unknown; error?: string } { const content = value.trim() if (!content) return { error: '外部工具结果不能为空' } @@ -463,6 +554,7 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { reasoning: parameters.reasoning, headers: parameters.headers, output: parameters.output, + agent: parameters.agent, } } @@ -480,6 +572,9 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { .find((item) => item.state === 'streaming') if (streamingMessage) { streamingMessage.state = 'stopped' + if (streamingMessage.agentDiagnostics) { + streamingMessage.agentDiagnostics.terminalState = 'stopped' + } if (streamingMessage.reasoningState === 'streaming') streamingMessage.reasoningState = 'done' } resetActiveRequest() @@ -495,6 +590,31 @@ export function useChatWorkbench(options: UseChatWorkbenchOptions) { function applyExamplePrompt(prompt: ExamplePrompt) { input.value = prompt.content + if (prompt.id.startsWith('agent-runtime-')) { + settings.agentModeEnabled.value = true + settings.agentProfile.value = 'BALANCED' + settings.agentMaxSteps.value = 20 + settings.agentStepPolicy.value = 'ALL_TOOLS' + settings.agentServerToolEnabled.value = true + settings.agentBrowserToolEnabled.value = false + settings.agentExternalToolEnabled.value = false + settings.agentApprovalRequired.value = false + settings.agentToolInputStreamEnabled.value = false + settings.agentRecoveryScenario.value = + prompt.id === 'agent-runtime-renamed' + ? 'RENAMED_TOOL' + : prompt.id === 'agent-runtime-failed' + ? 'FAILED_RECOVERY' + : 'NONE' + settings.outputMode.value = prompt.id === 'agent-runtime-structured' ? 'OBJECT' : 'TEXT' + if (prompt.id === 'agent-runtime-approval') { + settings.agentApprovalRequired.value = true + } + if (prompt.id === 'agent-runtime-external') { + settings.agentExternalToolEnabled.value = true + } + return + } if (prompt.id === 'tool-input-stream-test') { settings.testToolEnabled.value = false settings.testToolApprovalEnabled.value = false diff --git a/ui/src/composables/workbench/use-language-generation-settings.test.ts b/ui/src/composables/workbench/use-language-generation-settings.test.ts index 1e68dd85..dfde65f5 100644 --- a/ui/src/composables/workbench/use-language-generation-settings.test.ts +++ b/ui/src/composables/workbench/use-language-generation-settings.test.ts @@ -40,6 +40,18 @@ describe('useLanguageGenerationSettings', () => { reasoning: undefined, headers: { 'X-Trace': 'trace-1' }, output: { type: 'CHOICE', choices: ['yes', 'no'] }, + agent: { + enabled: false, + profile: 'BALANCED', + maxSteps: 20, + stepPolicy: 'ALL_TOOLS', + serverToolEnabled: true, + browserToolEnabled: false, + externalToolEnabled: false, + approvalRequired: false, + toolInputStreamEnabled: false, + recoveryScenario: 'NONE', + }, }) }) @@ -68,4 +80,36 @@ describe('useLanguageGenerationSettings', () => { expect(settings.streamOptions()).toMatchObject({ toolInputStreamTestEnabled: true }) }) + + it('builds the complete typed agent options and disables direct-mode query flags', async () => { + const settings = useLanguageGenerationSettings() + settings.testToolEnabled.value = true + settings.agentModeEnabled.value = true + settings.agentProfile.value = 'EXPLICIT' + settings.agentMaxSteps.value = 7 + settings.agentStepPolicy.value = 'SERVER_THEN_BROWSER' + settings.agentServerToolEnabled.value = false + await nextTick() + settings.agentBrowserToolEnabled.value = true + settings.agentExternalToolEnabled.value = true + settings.agentApprovalRequired.value = true + settings.agentToolInputStreamEnabled.value = true + settings.agentRecoveryScenario.value = 'RENAMED_TOOL' + await nextTick() + + expect(settings.agentServerToolEnabled.value).toBe(true) + expect(settings.buildValidatedParameters()?.agent).toEqual({ + enabled: true, + profile: 'EXPLICIT', + maxSteps: 7, + stepPolicy: 'SERVER_THEN_BROWSER', + serverToolEnabled: true, + browserToolEnabled: true, + externalToolEnabled: true, + approvalRequired: true, + toolInputStreamEnabled: true, + recoveryScenario: 'RENAMED_TOOL', + }) + expect(settings.streamOptions()).toEqual({}) + }) }) diff --git a/ui/src/composables/workbench/use-language-generation-settings.ts b/ui/src/composables/workbench/use-language-generation-settings.ts index f4ef07ae..14729609 100644 --- a/ui/src/composables/workbench/use-language-generation-settings.ts +++ b/ui/src/composables/workbench/use-language-generation-settings.ts @@ -1,6 +1,9 @@ import { buildOutputSpec, buildReasoningOptions, + type AgentProfile, + type AgentRecoveryScenario, + type AgentStepPolicy, type OutputMode, type ReasoningEffort, type ReasoningMode, @@ -32,6 +35,16 @@ export function useLanguageGenerationSettings() { const agentTestToolsEnabled = shallowRef(false) const toolCallRepairEnabled = shallowRef(false) const toolInputStreamTestEnabled = shallowRef(false) + const agentModeEnabled = shallowRef(false) + const agentProfile = shallowRef('BALANCED') + const agentMaxSteps = shallowRef(20) + const agentStepPolicy = shallowRef('ALL_TOOLS') + const agentServerToolEnabled = shallowRef(true) + const agentBrowserToolEnabled = shallowRef(false) + const agentExternalToolEnabled = shallowRef(false) + const agentApprovalRequired = shallowRef(false) + const agentToolInputStreamEnabled = shallowRef(false) + const agentRecoveryScenario = shallowRef('NONE') const outputMode = shallowRef('TEXT') const outputSchemaText = shallowRef(`{ "type": "object", @@ -80,6 +93,18 @@ export function useLanguageGenerationSettings() { }), headers, output, + agent: { + enabled: agentModeEnabled.value, + profile: agentProfile.value, + maxSteps: numberOrUndefined(agentMaxSteps.value), + stepPolicy: agentStepPolicy.value, + serverToolEnabled: agentServerToolEnabled.value, + browserToolEnabled: agentBrowserToolEnabled.value, + externalToolEnabled: agentExternalToolEnabled.value, + approvalRequired: agentApprovalRequired.value, + toolInputStreamEnabled: agentToolInputStreamEnabled.value, + recoveryScenario: agentRecoveryScenario.value, + }, } } @@ -100,6 +125,7 @@ export function useLanguageGenerationSettings() { } function streamOptions() { + if (agentModeEnabled.value) return {} return { testToolEnabled: testToolEnabled.value, testToolApprovalEnabled: testToolApprovalEnabled.value, @@ -116,6 +142,12 @@ export function useLanguageGenerationSettings() { watch(testToolEnabled, (enabled) => { if (!enabled) testToolApprovalEnabled.value = false }) + watch(agentApprovalRequired, (enabled) => { + if (enabled && !agentServerToolEnabled.value) agentServerToolEnabled.value = true + }) + watch(agentServerToolEnabled, (enabled) => { + if (!enabled) agentApprovalRequired.value = false + }) return { systemPrompt, @@ -141,6 +173,16 @@ export function useLanguageGenerationSettings() { agentTestToolsEnabled, toolCallRepairEnabled, toolInputStreamTestEnabled, + agentModeEnabled, + agentProfile, + agentMaxSteps, + agentStepPolicy, + agentServerToolEnabled, + agentBrowserToolEnabled, + agentExternalToolEnabled, + agentApprovalRequired, + agentToolInputStreamEnabled, + agentRecoveryScenario, outputMode, outputSchemaText, outputChoicesText, diff --git a/ui/src/utils/model-test-workbench.test.ts b/ui/src/utils/model-test-workbench.test.ts index 8a297207..a2091424 100644 --- a/ui/src/utils/model-test-workbench.test.ts +++ b/ui/src/utils/model-test-workbench.test.ts @@ -9,7 +9,9 @@ import { buildTestUiMessageChatRequest, createUserUIMessage, filterEnabledChatModels, + finalizeAgentRunDiagnostics, readTestUiMessageChatStream, + recordAgentRunChunk, testRagUiMessageStreamUrl, testUiMessageChatStreamUrl, workbenchDataPartSchemas, @@ -100,6 +102,13 @@ describe('buildTestUiMessageChatRequest', () => { maxOutputTokens: 128, reasoning: buildReasoningOptions({ mode: 'ENABLED' }), output: { type: 'JSON' } as OutputSpec, + agent: { + enabled: true, + profile: 'CONCISE', + maxSteps: 8, + stepPolicy: 'SERVER_THEN_ALL', + recoveryScenario: 'RENAMED_TOOL', + }, }), ).toMatchObject({ trigger: 'submit-message', @@ -117,6 +126,13 @@ describe('buildTestUiMessageChatRequest', () => { maxOutputTokens: 128, reasoning: { mode: 'ENABLED' }, output: { type: 'JSON' }, + agent: { + enabled: true, + profile: 'CONCISE', + maxSteps: 8, + stepPolicy: 'SERVER_THEN_ALL', + recoveryScenario: 'RENAMED_TOOL', + }, messages: [ { id: 'user-1', @@ -238,6 +254,80 @@ describe('buildTestUiMessageChatRequest', () => { }) }) +describe('agent run diagnostics', () => { + it('merges backend policy, step usage, warnings, and recovered tool identity', () => { + let diagnostics = recordAgentRunChunk( + { enabled: true, tools: [], steps: [], warnings: [] }, + { + type: 'tool-input-start', + toolCallId: 'call_1', + toolName: 'halo_legacy_repair_test_info', + state: 'input-streaming', + }, + ) + diagnostics = recordAgentRunChunk(diagnostics, { + type: 'tool-call', + toolCallId: 'call_1', + toolName: 'halo_repair_test_info', + state: 'input-available', + input: { query: 'Halo' }, + stepIndex: 0, + }) + diagnostics = recordAgentRunChunk(diagnostics, { + type: 'finish-step', + stepIndex: 0, + finishReason: 'TOOL_CALLS', + usage: { inputTokens: 12, outputTokens: 4 }, + warnings: [{ code: 'tool-call-repaired', message: 'Recovered tool name' }], + request: { + metadata: { + agentDiagnostics: { + enabled: true, + profile: 'CONCISE', + effectiveInstructions: 'Be concise.', + maximumSteps: 20, + completedSteps: 1, + activeTools: ['halo_repair_test_info'], + callPreparationCount: 1, + stepPreparation: [ + { stepIndex: 0, activeTools: ['halo_repair_test_info'], policy: 'ALL_TOOLS' }, + ], + }, + }, + }, + }) + diagnostics = recordAgentRunChunk(diagnostics, { type: 'finish' }) + + expect(diagnostics).toMatchObject({ + profile: 'CONCISE', + effectiveInstructions: 'Be concise.', + maximumSteps: 20, + completedSteps: 1, + callPreparationCount: 1, + terminalState: 'done', + tools: [ + { + toolCallId: 'call_1', + originalToolName: 'halo_legacy_repair_test_info', + resolvedToolName: 'halo_repair_test_info', + input: { query: 'Halo' }, + }, + ], + steps: [{ stepIndex: 0, finishReason: 'TOOL_CALLS' }], + warnings: [{ code: 'tool-call-repaired' }], + }) + }) + + it('renders a separately parsed final structured value only for agent structured output', () => { + expect( + finalizeAgentRunDiagnostics({ enabled: true, outputMode: 'OBJECT' }, '{"title":"Halo"}'), + ).toMatchObject({ finalOutput: { title: 'Halo' } }) + expect( + finalizeAgentRunDiagnostics({ enabled: true, outputMode: 'TEXT' }, '{"title":"Halo"}'), + ).not.toHaveProperty('finalOutput') + }) +}) + describe('buildReasoningOptions', () => { it('builds typed reasoning payloads', () => { expect(buildReasoningOptions({ mode: 'DEFAULT' })).toBeUndefined() @@ -276,17 +366,21 @@ describe('buildOutputSpec', () => { }) describe('testUiMessageChatStreamUrl', () => { - it('uses the UI Message stream path with the shared console flags', () => { - expect( - testUiMessageChatStreamUrl('model/name', { - testToolEnabled: true, - externalTestToolEnabled: true, - agentTestToolsEnabled: true, - toolCallRepairEnabled: true, - toolInputStreamTestEnabled: true, - }), - ).toBe( - '/apis/console.api.aifoundation.halo.run/v1alpha1/models/model%2Fname/test-chat/ui-message/stream?enableTestTool=true&enableExternalTestTool=true&enableAgentTestTools=true&enableToolCallRepair=true&enableToolInputStreamTest=true', + it('uses the generated UI Message endpoint binding with shared console flags', async () => { + await expect( + testUiMessageChatStreamUrl( + 'model/name', + { id: 'chat', messages: [] }, + { + testToolEnabled: true, + externalTestToolEnabled: true, + agentTestToolsEnabled: true, + toolCallRepairEnabled: true, + toolInputStreamTestEnabled: true, + }, + ), + ).resolves.toBe( + '/apis/console.api.aifoundation.halo.run/v1alpha1/models/model%2Fname/test-chat/ui-message/stream?enableTestTool=true&enableExternalTestTool=true&enableToolCallRepair=true&enableAgentTestTools=true&enableToolInputStreamTest=true', ) }) }) diff --git a/ui/src/utils/model-test-workbench.ts b/ui/src/utils/model-test-workbench.ts index ac1b4322..da1feb2c 100644 --- a/ui/src/utils/model-test-workbench.ts +++ b/ui/src/utils/model-test-workbench.ts @@ -1,5 +1,13 @@ -import type { AiModel, OutputSpec, TestUiMessageChatRequest } from '@/api/generated' -import { AiModelSpecModelTypeEnum } from '@/api/generated' +import type { + AiModel, + OutputSpec, + TestAgentOptions, + TestUiMessageChatRequest, +} from '@/api/generated' +import { + AiModelSpecModelTypeEnum, + ConsoleApiAifoundationHaloRunV1alpha1ModelApiAxiosParamCreator, +} from '@/api/generated' import { DefaultChatTransport, type DataPartSchemas, @@ -29,6 +37,72 @@ export interface WorkbenchMessage { files?: WorkbenchFileReference[] ragInput?: RagInputDiagnostics ragDiagnostics?: RagRunDiagnostics + agentDiagnostics?: AgentRunDiagnostics +} + +export type AgentProfile = NonNullable +export type AgentStepPolicy = NonNullable +export type AgentRecoveryScenario = NonNullable + +export interface AgentWorkbenchOptions { + enabled: boolean + profile: AgentProfile + maxSteps: number + stepPolicy: AgentStepPolicy + serverToolEnabled: boolean + browserToolEnabled: boolean + externalToolEnabled: boolean + approvalRequired: boolean + toolInputStreamEnabled: boolean + recoveryScenario: AgentRecoveryScenario +} + +export interface AgentStepPreparationDiagnostic { + stepIndex?: number + activeTools?: string[] + policy?: string +} + +export interface AgentToolDiagnostic { + toolCallId: string + originalToolName?: string + resolvedToolName?: string + state?: string + stepIndex?: number + input?: Record + output?: unknown + errorText?: string +} + +export interface AgentStepDiagnostic { + stepIndex?: number + finishReason?: string + usage?: Record + warnings?: WorkbenchWarning[] + request?: Record + response?: Record +} + +export interface AgentRunDiagnostics { + enabled?: boolean + profile?: string + effectiveInstructions?: string + maximumSteps?: number + completedSteps?: number + stepPolicy?: string + activeTools?: string[] + outputMode?: string + approvalRequired?: boolean + externalToolEnabled?: boolean + browserToolEnabled?: boolean + recoveryScenario?: string + callPreparationCount?: number + stepPreparation?: AgentStepPreparationDiagnostic[] + steps?: AgentStepDiagnostic[] + tools?: AgentToolDiagnostic[] + warnings?: WorkbenchWarning[] + terminalState?: WorkbenchMessage['state'] + finalOutput?: unknown } export interface WorkbenchWarning { @@ -172,6 +246,45 @@ export const EXAMPLE_PROMPTS: ExamplePrompt[] = [ content: '这是流式工具入参专项测试。你必须且只能调用一次 halo_tool_input_stream_test,不要调用其他工具。参数 title 请填写“AI Foundation 流式工具入参测试”;payload 请完整填写“这是一段用于观察 JSON 工具参数是否按多个增量片段持续到达浏览器与后端回调的测试内容,请保持本句完整,不要缩写。”;sequence 请依次填写 ["start", "delta", "available"]。获得工具结果后,请简要说明 backendLifecycle 中的事件顺序和 deltaCount。', }, + { + id: 'agent-runtime-server', + icon: 'ri-robot-2-line', + title: 'Agent 服务端工具', + content: '请调用 halo_test_info,query 填写“agent runtime”,得到结果后继续并给出最终答复。', + }, + { + id: 'agent-runtime-approval', + icon: 'ri-shield-check-line', + title: 'Agent 工具审批', + content: '请调用 halo_test_info,query 填写“approval flow”,等待管理员审批后再继续。', + }, + { + id: 'agent-runtime-external', + icon: 'ri-external-link-line', + title: 'Agent 外部工具', + content: + '请调用 halo_external_test_info,query 填写“external continuation”,等待浏览器提交结果后再继续。', + }, + { + id: 'agent-runtime-renamed', + icon: 'ri-git-merge-line', + title: 'Agent 工具更名恢复', + content: + '请调用旧工具 halo_legacy_repair_test_info,参数 message 填写“renamed tool recovery”,得到恢复后的结果再回答。', + }, + { + id: 'agent-runtime-failed', + icon: 'ri-error-warning-line', + title: 'Agent 恢复失败', + content: + '请调用不存在的工具 halo_legacy_repair_test_info,参数 message 填写“failed recovery”,并观察错误与 warning。', + }, + { + id: 'agent-runtime-structured', + icon: 'ri-braces-line', + title: 'Agent 结构化输出', + content: '请返回标题为 Halo、摘要为 Agent runtime verified 的结构化结果。', + }, ] export async function copyToClipboard(text: string): Promise { @@ -202,6 +315,7 @@ export interface ChatParameters { reasoning?: ReasoningOptions headers?: Record output?: OutputSpec + agent?: TestAgentOptions } export type OutputMode = 'TEXT' | 'OBJECT' | 'ARRAY' | 'CHOICE' | 'JSON' @@ -333,6 +447,10 @@ export interface UIMessageChunk { metadata?: Record providerMetadata?: Record warnings?: WorkbenchWarning[] + finishReason?: string + usage?: Record + request?: Record + response?: Record } function workbenchDefinedDataSchema(name: string) { @@ -476,6 +594,7 @@ export function buildTestUiMessageChatRequest( reasoning: parameters.reasoning, headers: parameters.headers, output: parameters.output, + agent: parameters.agent, } } @@ -532,7 +651,11 @@ export async function readTestUiMessageChatStream(options: { }) { const requestBody = options.requestBody as Record const transport = new DefaultChatTransport({ - api: testUiMessageChatStreamUrl(options.modelName, options.streamOptions), + api: await testUiMessageChatStreamUrl( + options.modelName, + options.requestBody, + options.streamOptions, + ), fetch, }) const stream = await transport.sendMessages({ @@ -550,39 +673,29 @@ export async function readTestUiMessageChatStream(options: { } } -export function testUiMessageChatStreamUrl(modelName: string, options: ChatStreamOptions) { - const params = chatStreamQueryParams(options) - const query = params.toString() - return `/apis/console.api.aifoundation.halo.run/v1alpha1/models/${encodeURIComponent(modelName)}/test-chat/ui-message/stream${query ? `?${query}` : ''}` +export async function testUiMessageChatStreamUrl( + modelName: string, + requestBody: TestUiMessageChatRequest, + options: ChatStreamOptions, +) { + const request = + await ConsoleApiAifoundationHaloRunV1alpha1ModelApiAxiosParamCreator().testModelUiMessageChatStream( + modelName, + requestBody, + options.testToolEnabled, + options.testToolApprovalEnabled, + options.externalTestToolEnabled, + options.toolCallRepairEnabled, + options.agentTestToolsEnabled, + options.toolInputStreamTestEnabled, + ) + return request.url } export function testRagUiMessageStreamUrl(modelName: string) { return `/apis/console.api.aifoundation.halo.run/v1alpha1/models/${encodeURIComponent(modelName)}/test-rag/ui-message/stream` } -function chatStreamQueryParams(options: ChatStreamOptions) { - const params = new URLSearchParams() - if (options.testToolEnabled) { - params.set('enableTestTool', 'true') - } - if (options.testToolApprovalEnabled) { - params.set('enableTestToolApproval', 'true') - } - if (options.externalTestToolEnabled) { - params.set('enableExternalTestTool', 'true') - } - if (options.agentTestToolsEnabled) { - params.set('enableAgentTestTools', 'true') - } - if (options.toolCallRepairEnabled) { - params.set('enableToolCallRepair', 'true') - } - if (options.toolInputStreamTestEnabled) { - params.set('enableToolInputStreamTest', 'true') - } - return params -} - export function applyWorkbenchUIMessageChunk(message: WorkbenchMessage, chunk: UIMessageChunk) { const uiMessage = ensureAssistantUIMessage(message) if (chunk.type === 'start') { @@ -731,6 +844,85 @@ export function applyWorkbenchUIMessageChunk(message: WorkbenchMessage, chunk: U } } +export function recordAgentRunChunk( + current: AgentRunDiagnostics | undefined, + chunk: UIMessageChunk, +): AgentRunDiagnostics | undefined { + const backend = agentDiagnosticsFromRequest(chunk.request) + const hasAgentEvidence = current?.enabled || backend?.enabled + if (!hasAgentEvidence) return current + + const next: AgentRunDiagnostics = { + ...current, + ...backend, + stepPreparation: [...(backend?.stepPreparation || current?.stepPreparation || [])], + steps: [...(current?.steps || [])], + tools: [...(current?.tools || [])], + warnings: [...(current?.warnings || [])], + } + if (chunk.type === 'finish-step') { + const step: AgentStepDiagnostic = { + stepIndex: chunk.stepIndex, + finishReason: chunk.finishReason, + usage: chunk.usage, + warnings: chunk.warnings, + request: chunk.request, + response: chunk.response, + } + const existingIndex = next.steps!.findIndex((item) => item.stepIndex === step.stepIndex) + if (existingIndex >= 0) next.steps![existingIndex] = step + else next.steps!.push(step) + next.completedSteps = Math.max(next.completedSteps || 0, next.steps!.length) + if (chunk.warnings?.length) next.warnings!.push(...chunk.warnings) + } + if (chunk.toolCallId && isUIMessageToolPartType(chunk.type)) { + const existingIndex = next.tools!.findIndex((item) => item.toolCallId === chunk.toolCallId) + const existing = existingIndex >= 0 ? next.tools![existingIndex] : undefined + const originalToolName = existing?.originalToolName || chunk.toolName + const resolvedToolName = + existing?.resolvedToolName || + (originalToolName && chunk.toolName && originalToolName !== chunk.toolName + ? chunk.toolName + : undefined) + const tool: AgentToolDiagnostic = { + ...existing, + toolCallId: chunk.toolCallId, + originalToolName, + resolvedToolName, + state: chunk.state || chunk.type, + stepIndex: chunk.stepIndex ?? existing?.stepIndex, + input: chunk.input || existing?.input, + output: chunk.output ?? existing?.output, + errorText: chunk.errorText || existing?.errorText, + } + if (existingIndex >= 0) next.tools![existingIndex] = tool + else next.tools!.push(tool) + } + if (chunk.type === 'finish') next.terminalState = 'done' + if (chunk.type === 'abort') next.terminalState = 'stopped' + if (chunk.type === 'error') next.terminalState = 'error' + return next +} + +export function finalizeAgentRunDiagnostics( + diagnostics: AgentRunDiagnostics | undefined, + answerText: string, +): AgentRunDiagnostics | undefined { + if (!diagnostics?.enabled || diagnostics.outputMode === 'TEXT') return diagnostics + try { + return { ...diagnostics, finalOutput: JSON.parse(answerText.trim()) } + } catch { + return diagnostics + } +} + +function agentDiagnosticsFromRequest(request: unknown): AgentRunDiagnostics | undefined { + if (!isPlainRecord(request)) return undefined + const metadata = request.metadata + if (!isPlainRecord(metadata) || !isPlainRecord(metadata.agentDiagnostics)) return undefined + return metadata.agentDiagnostics as AgentRunDiagnostics +} + export function applyWorkbenchUIMessageSnapshot( message: WorkbenchMessage, uiMessage: UIMessage>, diff --git a/ui/src/views/ModelTestWorkbenchView.vue b/ui/src/views/ModelTestWorkbenchView.vue index 1e20a5c6..3ec964ac 100644 --- a/ui/src/views/ModelTestWorkbenchView.vue +++ b/ui/src/views/ModelTestWorkbenchView.vue @@ -61,6 +61,16 @@ const { agentTestToolsEnabled, toolCallRepairEnabled, toolInputStreamTestEnabled, + agentModeEnabled, + agentProfile, + agentMaxSteps, + agentStepPolicy, + agentServerToolEnabled, + agentBrowserToolEnabled, + agentExternalToolEnabled, + agentApprovalRequired, + agentToolInputStreamEnabled, + agentRecoveryScenario, outputMode, outputSchemaText, outputChoicesText, @@ -409,6 +419,16 @@ function distanceToConversationBottom(element: HTMLElement) { :agent-test-tools-enabled="agentTestToolsEnabled" :tool-call-repair-enabled="toolCallRepairEnabled" :tool-input-stream-test-enabled="toolInputStreamTestEnabled" + :agent-mode-enabled="agentModeEnabled" + :agent-profile="agentProfile" + :agent-max-steps="agentMaxSteps" + :agent-step-policy="agentStepPolicy" + :agent-server-tool-enabled="agentServerToolEnabled" + :agent-browser-tool-enabled="agentBrowserToolEnabled" + :agent-external-tool-enabled="agentExternalToolEnabled" + :agent-approval-required="agentApprovalRequired" + :agent-tool-input-stream-enabled="agentToolInputStreamEnabled" + :agent-recovery-scenario="agentRecoveryScenario" :output-mode="outputMode" :output-schema-text="outputSchemaText" :output-choices-text="outputChoicesText" @@ -452,6 +472,16 @@ function distanceToConversationBottom(element: HTMLElement) { @update:agent-test-tools-enabled="agentTestToolsEnabled = $event" @update:tool-call-repair-enabled="toolCallRepairEnabled = $event" @update:tool-input-stream-test-enabled="toolInputStreamTestEnabled = $event" + @update:agent-mode-enabled="agentModeEnabled = $event" + @update:agent-profile="agentProfile = $event" + @update:agent-max-steps="agentMaxSteps = $event" + @update:agent-step-policy="agentStepPolicy = $event" + @update:agent-server-tool-enabled="agentServerToolEnabled = $event" + @update:agent-browser-tool-enabled="agentBrowserToolEnabled = $event" + @update:agent-external-tool-enabled="agentExternalToolEnabled = $event" + @update:agent-approval-required="agentApprovalRequired = $event" + @update:agent-tool-input-stream-enabled="agentToolInputStreamEnabled = $event" + @update:agent-recovery-scenario="agentRecoveryScenario = $event" @update:output-mode="outputMode = $event" @update:output-schema-text="outputSchemaText = $event" @update:output-choices-text="outputChoicesText = $event" diff --git a/ui/src/views/components/workbench/AgentParameterPanel.test.ts b/ui/src/views/components/workbench/AgentParameterPanel.test.ts new file mode 100644 index 00000000..79bb2778 --- /dev/null +++ b/ui/src/views/components/workbench/AgentParameterPanel.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, rstest } from '@rstest/core' +import { mount } from '@vue/test-utils' +import { defineComponent, h } from 'vue' +import AgentParameterPanel from './AgentParameterPanel.vue' + +rstest.mock('@halo-dev/components', () => ({ + VSwitch: defineComponent({ + props: { modelValue: Boolean, disabled: Boolean }, + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h('button', { + type: 'button', + disabled: props.disabled, + 'aria-checked': props.modelValue, + onClick: () => emit('update:modelValue', !props.modelValue), + }) + }, + }), +})) + +describe('AgentParameterPanel', () => { + it('emits mode, typed profile, step policy, recovery, and tool controls', async () => { + const wrapper = mount(AgentParameterPanel, { + props: { + enabled: true, + profile: 'BALANCED', + maxSteps: 20, + stepPolicy: 'ALL_TOOLS', + serverToolEnabled: true, + recoveryScenario: 'NONE', + }, + }) + + await wrapper.findAll('button')[0]?.trigger('click') + const selects = wrapper.findAll('select') + await selects[0]?.setValue('CONCISE') + await wrapper.get('input[type="number"]').setValue('8') + await selects[1]?.setValue('SERVER_THEN_BROWSER') + await selects[2]?.setValue('RENAMED_TOOL') + await wrapper.findAll('button')[2]?.trigger('click') + + expect(wrapper.emitted('update:enabled')).toEqual([[false]]) + expect(wrapper.emitted('update:profile')).toEqual([['CONCISE']]) + expect(wrapper.emitted('update:maxSteps')).toEqual([[8]]) + expect(wrapper.emitted('update:stepPolicy')).toEqual([['SERVER_THEN_BROWSER']]) + expect(wrapper.emitted('update:recoveryScenario')).toEqual([['RENAMED_TOOL']]) + expect(wrapper.emitted('update:browserToolEnabled')).toEqual([[true]]) + }) +}) diff --git a/ui/src/views/components/workbench/AgentParameterPanel.vue b/ui/src/views/components/workbench/AgentParameterPanel.vue new file mode 100644 index 00000000..5af2ad5d --- /dev/null +++ b/ui/src/views/components/workbench/AgentParameterPanel.vue @@ -0,0 +1,172 @@ + + + diff --git a/ui/src/views/components/workbench/AgentRunDiagnostics.test.ts b/ui/src/views/components/workbench/AgentRunDiagnostics.test.ts new file mode 100644 index 00000000..375ac044 --- /dev/null +++ b/ui/src/views/components/workbench/AgentRunDiagnostics.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it } from '@rstest/core' +import { mount, type VueWrapper } from '@vue/test-utils' +import AgentRunDiagnostics from './AgentRunDiagnostics.vue' + +const wrappers: VueWrapper[] = [] + +describe('AgentRunDiagnostics', () => { + afterEach(() => { + for (const wrapper of wrappers.splice(0)) wrapper.unmount() + }) + + it('renders terminal policy, recovery identity, warnings, usage, and final output', () => { + const wrapper = mount(AgentRunDiagnostics, { + props: { + diagnostics: { + enabled: true, + profile: 'CONCISE', + effectiveInstructions: 'Be concise.', + maximumSteps: 20, + completedSteps: 2, + stepPolicy: 'SERVER_THEN_ALL', + outputMode: 'OBJECT', + recoveryScenario: 'RENAMED_TOOL', + callPreparationCount: 1, + terminalState: 'done', + activeTools: ['halo_repair_test_info'], + steps: [ + { + stepIndex: 0, + finishReason: 'TOOL_CALLS', + usage: { inputTokens: 10, outputTokens: 4 }, + }, + ], + tools: [ + { + toolCallId: 'call_1', + originalToolName: 'halo_legacy_repair_test_info', + resolvedToolName: 'halo_repair_test_info', + state: 'output-available', + output: { ok: true }, + }, + ], + warnings: [{ code: 'tool-call-repaired', message: 'Recovered' }], + finalOutput: { title: 'Halo' }, + }, + }, + }) + wrappers.push(wrapper) + + expect(wrapper.text()).toContain('2 / 20') + expect(wrapper.text()).toContain('CONCISE') + expect(wrapper.text()).toContain('halo_legacy_repair_test_info') + expect(wrapper.text()).toContain('halo_repair_test_info') + expect(wrapper.text()).toContain('call_1') + expect(wrapper.text()).toContain('tool-call-repaired') + expect(wrapper.text()).toContain('inputTokens') + expect(wrapper.text()).toContain('已校验的最终结构化值') + expect(wrapper.text()).toContain('done') + }) +}) diff --git a/ui/src/views/components/workbench/AgentRunDiagnostics.vue b/ui/src/views/components/workbench/AgentRunDiagnostics.vue new file mode 100644 index 00000000..6c1f80e7 --- /dev/null +++ b/ui/src/views/components/workbench/AgentRunDiagnostics.vue @@ -0,0 +1,151 @@ + + + diff --git a/ui/src/views/components/workbench/ChatMessageItem.vue b/ui/src/views/components/workbench/ChatMessageItem.vue index 65dfde62..eb3fccc9 100644 --- a/ui/src/views/components/workbench/ChatMessageItem.vue +++ b/ui/src/views/components/workbench/ChatMessageItem.vue @@ -15,6 +15,7 @@ import RiFileLine from '~icons/ri/file-line' import RiImageLine from '~icons/ri/image-line' import RiRestartLine from '~icons/ri/restart-line' import RiUserLine from '~icons/ri/user-line' +import AgentRunDiagnostics from './AgentRunDiagnostics.vue' import ToolInputStreamDiagnostics from './ToolInputStreamDiagnostics.vue' const props = defineProps<{ @@ -226,6 +227,11 @@ function filePreviewUrl(file: WorkbenchFileReference) { + +

-import type { OutputMode, ReasoningEffort, ReasoningMode } from '@/utils/model-test-workbench' +import type { + AgentProfile, + AgentRecoveryScenario, + AgentStepPolicy, + OutputMode, + ReasoningEffort, + ReasoningMode, +} from '@/utils/model-test-workbench' import RiSettings3Line from '~icons/ri/settings-3-line' +import AgentParameterPanel from './AgentParameterPanel.vue' import ChatParameterPanel from './ChatParameterPanel.vue' import EmbeddingParameterPanel from './EmbeddingParameterPanel.vue' import ImageParameterPanel from './ImageParameterPanel.vue' @@ -32,6 +40,16 @@ defineProps<{ agentTestToolsEnabled?: boolean toolCallRepairEnabled?: boolean toolInputStreamTestEnabled?: boolean + agentModeEnabled?: boolean + agentProfile?: AgentProfile + agentMaxSteps?: number + agentStepPolicy?: AgentStepPolicy + agentServerToolEnabled?: boolean + agentBrowserToolEnabled?: boolean + agentExternalToolEnabled?: boolean + agentApprovalRequired?: boolean + agentToolInputStreamEnabled?: boolean + agentRecoveryScenario?: AgentRecoveryScenario outputMode?: OutputMode outputSchemaText?: string outputChoicesText?: string @@ -78,6 +96,16 @@ const emit = defineEmits<{ 'update:agentTestToolsEnabled': [value: boolean] 'update:toolCallRepairEnabled': [value: boolean] 'update:toolInputStreamTestEnabled': [value: boolean] + 'update:agentModeEnabled': [value: boolean] + 'update:agentProfile': [value: AgentProfile] + 'update:agentMaxSteps': [value: number] + 'update:agentStepPolicy': [value: AgentStepPolicy] + 'update:agentServerToolEnabled': [value: boolean] + 'update:agentBrowserToolEnabled': [value: boolean] + 'update:agentExternalToolEnabled': [value: boolean] + 'update:agentApprovalRequired': [value: boolean] + 'update:agentToolInputStreamEnabled': [value: boolean] + 'update:agentRecoveryScenario': [value: AgentRecoveryScenario] 'update:outputMode': [value: OutputMode] 'update:outputSchemaText': [value: string] 'update:outputChoicesText': [value: string] @@ -113,65 +141,89 @@ const emit = defineEmits<{
- +